Skip to content

Commit b31dec9

Browse files
committed
Tests and prettier
1 parent 537a139 commit b31dec9

File tree

6 files changed

+230
-57
lines changed

6 files changed

+230
-57
lines changed

src/app/components/form-header/form-header.tsx

+5-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import useLanguageContext from '~/contexts/language';
55

66
import './form-header.scss';
77

8-
function FormHeader({data, prefix}: {
8+
function FormHeader({
9+
data,
10+
prefix
11+
}: {
912
data: Record<string, string>;
1013
prefix: string;
1114
}) {
@@ -28,7 +31,5 @@ export default function FormHeaderLoader({prefix}: {prefix: string}) {
2831
const {language} = useLanguageContext();
2932
const slug = `${slugBase}?locale=${language}`;
3033

31-
return (
32-
<LoaderPage slug={slug} Child={FormHeader} props={{prefix}} />
33-
);
34+
return <LoaderPage slug={slug} Child={FormHeader} props={{prefix}} />;
3435
}

src/app/components/form-input/form-input.tsx

+84-38
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,18 @@ import {useMainSticky} from '~/helpers/main-class-hooks';
44
import cn from 'classnames';
55
import './form-input.scss';
66

7+
// Accessibility issues here:
8+
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-autocomplete
9+
710
const LIMIT_SUGGESTIONS = 400;
811

9-
function SuggestionItem({value, accept, index, activeIndex, setActiveIndex}: {
12+
function SuggestionItem({
13+
value,
14+
accept,
15+
index,
16+
activeIndex,
17+
setActiveIndex
18+
}: {
1019
value: string;
1120
accept: (v: string) => void;
1221
index: number;
@@ -24,30 +33,42 @@ function SuggestionItem({value, accept, index, activeIndex, setActiveIndex}: {
2433

2534
return (
2635
<div
27-
className={cn('suggestion', {active})} ref={ref}
36+
className={cn('suggestion', {active})}
37+
ref={ref}
2838
onClick={() => accept(value)}
2939
onMouseMove={() => setActiveIndex(index)}
30-
>{value}</div>
40+
>
41+
{value}
42+
</div>
3143
);
3244
}
3345

34-
function useMatches(pattern: string, suggestions: string[]=[]) {
46+
function useMatches(pattern: string, suggestions: string[] = []) {
3547
const matches = React.useMemo(
36-
() => pattern.length > 1 ?
37-
suggestions.filter((s) => s.toLowerCase().includes(pattern)) :
38-
[],
48+
() =>
49+
pattern.length > 1
50+
? suggestions.filter((s) => s.toLowerCase().includes(pattern))
51+
: [],
3952
[pattern, suggestions]
4053
);
4154
const exactMatch = React.useMemo(
42-
() => matches.includes(pattern) ||
55+
() =>
56+
matches.includes(pattern) ||
4357
(matches.length === 1 && pattern === matches[0].toLowerCase()),
4458
[matches, pattern]
4559
);
4660

4761
return [matches, exactMatch] as const;
4862
}
4963

50-
function SuggestionBox({matches, exactMatch, accepted, accept, activeIndex, setActiveIndex}: {
64+
function SuggestionBox({
65+
matches,
66+
exactMatch,
67+
accepted,
68+
accept,
69+
activeIndex,
70+
setActiveIndex
71+
}: {
5172
matches: string[];
5273
exactMatch: boolean;
5374
accepted: boolean;
@@ -63,27 +84,40 @@ function SuggestionBox({matches, exactMatch, accepted, accept, activeIndex, setA
6384
return (
6485
<div className="suggestions">
6586
<div className="suggestion-box">
66-
{
67-
!exactMatch && !accepted && matches.slice(0, LIMIT_SUGGESTIONS).map((match, i) =>
68-
<SuggestionItem
69-
value={match} accept={accept} index={i}
70-
activeIndex={activeIndex} setActiveIndex={setActiveIndex}
71-
key={match}
72-
/>)
73-
}
74-
{
75-
matches.length > LIMIT_SUGGESTIONS &&
76-
<div className="suggestion"><i>List truncated</i></div>
77-
}
87+
{!exactMatch &&
88+
!accepted &&
89+
matches
90+
.slice(0, LIMIT_SUGGESTIONS)
91+
.map((match, i) => (
92+
<SuggestionItem
93+
value={match}
94+
accept={accept}
95+
index={i}
96+
activeIndex={activeIndex}
97+
setActiveIndex={setActiveIndex}
98+
key={match}
99+
/>
100+
))}
101+
{matches.length > LIMIT_SUGGESTIONS && (
102+
<div className="suggestion">
103+
<i>List truncated</i>
104+
</div>
105+
)}
78106
</div>
79107
</div>
80108
);
81109
}
82110

83-
type InputProps = {Tag?: keyof JSX.IntrinsicElements}
84-
& React.InputHTMLAttributes<HTMLInputElement>;
111+
type InputProps = {
112+
Tag?: keyof JSX.IntrinsicElements;
113+
} & React.InputHTMLAttributes<HTMLInputElement>;
85114

86-
function ValidatingInput({value, inputProps, onChange, accepted}: {
115+
function ValidatingInput({
116+
value,
117+
inputProps,
118+
onChange,
119+
accepted
120+
}: {
87121
value: string;
88122
inputProps: InputProps;
89123
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
@@ -112,22 +146,32 @@ function ValidatingInput({value, inputProps, onChange, accepted}: {
112146
}
113147

114148
// eslint-disable-next-line complexity
115-
export default function FormInput({label, longLabel, inputProps, suggestions}: {
149+
export default function FormInput({
150+
label,
151+
longLabel,
152+
inputProps,
153+
suggestions
154+
}: {
116155
label: string;
117156
longLabel?: string;
118157
inputProps: InputProps;
119158
suggestions?: string[];
120159
}) {
121160
const [value, setValue] = useState(inputProps.value?.toString() ?? '');
122161
const {onChange: otherOnChange, ...otherProps} = inputProps;
123-
const [matches, exactMatch] = useMatches(value.toString().toLowerCase(), suggestions);
162+
const [matches, exactMatch] = useMatches(
163+
value.toString().toLowerCase(),
164+
suggestions
165+
);
124166
const [accepted, setAccepted] = useState(!suggestions?.length);
125167
const accept = React.useCallback(
126168
(item: string) => {
127169
setValue(item);
128170
setAccepted(true);
129171
if (otherOnChange) {
130-
otherOnChange({target: {value: item}} as React.ChangeEvent<HTMLInputElement>);
172+
otherOnChange({
173+
target: {value: item}
174+
} as React.ChangeEvent<HTMLInputElement>);
131175
}
132176
},
133177
[otherOnChange]
@@ -167,23 +211,25 @@ export default function FormInput({label, longLabel, inputProps, suggestions}: {
167211
<label className="form-input">
168212
<div className="control-group">
169213
{label && <label className="field-label">{label}</label>}
170-
{longLabel && <label className="field-long-label">{longLabel}</label>}
214+
{longLabel && (
215+
<label className="field-long-label">{longLabel}</label>
216+
)}
171217
<ValidatingInput
172218
value={value}
173219
inputProps={{onKeyDown, ...otherProps}}
174220
onChange={onChange}
175221
accepted={accepted}
176222
/>
177-
{
178-
suggestions &&
179-
<SuggestionBox
180-
matches={matches}
181-
exactMatch={exactMatch}
182-
accepted={accepted}
183-
accept={accept} activeIndex={activeIndex}
184-
setActiveIndex={setActiveIndex}
185-
/>
186-
}
223+
{suggestions && (
224+
<SuggestionBox
225+
matches={matches}
226+
exactMatch={exactMatch}
227+
accepted={accepted}
228+
accept={accept}
229+
activeIndex={activeIndex}
230+
setActiveIndex={setActiveIndex}
231+
/>
232+
)}
187233
</div>
188234
</label>
189235
);

src/app/components/form-radiogroup/form-radiogroup.tsx

+27-14
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@ type OptionItem = {
55
value: string;
66
label: string;
77
checked?: boolean;
8-
}
8+
};
99

10-
function Option({item, name, required, selectedValue, onChange}: {
10+
function Option({
11+
item,
12+
name,
13+
required,
14+
selectedValue,
15+
onChange
16+
}: {
1117
item: OptionItem;
1218
name: string;
1319
required?: boolean;
@@ -33,10 +39,13 @@ function Option({item, name, required, selectedValue, onChange}: {
3339

3440
type InputElementWithValidationMessage = HTMLInputElement & {
3541
validationMessage: string;
36-
}
42+
};
3743

3844
export default function FormRadioGroup({
39-
longLabel, name, options, required
45+
longLabel,
46+
name,
47+
options,
48+
required
4049
}: {
4150
longLabel?: string;
4251
name: string;
@@ -47,14 +56,14 @@ export default function FormRadioGroup({
4756
const [validationMessage, setValidationMessage] = useState('');
4857
const checkedValue = options.find((opt) => opt.checked)?.value;
4958
const [selectedValue, setSelectedValue] = useState(checkedValue);
50-
const validate = React.useCallback(
51-
() => {
52-
const invalid = ref.current?.querySelector<InputElementWithValidationMessage>(':invalid');
59+
const validate = React.useCallback(() => {
60+
const invalid =
61+
ref.current?.querySelector<InputElementWithValidationMessage>(
62+
':invalid'
63+
);
5364

54-
setValidationMessage(invalid ? invalid.validationMessage : '');
55-
},
56-
[]
57-
);
65+
setValidationMessage(invalid ? invalid.validationMessage : '');
66+
}, []);
5867
const onChange = React.useCallback(
5968
({target: {value}}: React.ChangeEvent<HTMLInputElement>) => {
6069
setSelectedValue(value);
@@ -70,10 +79,14 @@ export default function FormRadioGroup({
7079
React.useEffect(validate, [validate]);
7180

7281
return (
73-
<div className='form-radiogroup'>
74-
{longLabel && <label className="field-long-label">{longLabel}</label>}
82+
<div className="form-radiogroup">
83+
{longLabel && (
84+
<label className="field-long-label">{longLabel}</label>
85+
)}
7586
<div ref={ref}>
76-
{options.map((item) => <Option item={item} {...passThruProps} key={item.value} />)}
87+
{options.map((item) => (
88+
<Option item={item} {...passThruProps} key={item.value} />
89+
))}
7790
</div>
7891
<div className="invalid-message">{validationMessage}</div>
7992
</div>
+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import React from 'react';
2+
import {render, screen, fireEvent} from '@testing-library/preact';
3+
import FormInput from '~/components/form-input/form-input';
4+
import userEvent from '@testing-library/user-event';
5+
6+
jest.mock('~/helpers/main-class-hooks');
7+
8+
describe('components/form-input', () => {
9+
const user = userEvent.setup();
10+
11+
it('handles various key-downs', async () => {
12+
render(
13+
<FormInput
14+
label="label"
15+
inputProps={{}}
16+
longLabel="this is a long label"
17+
/>
18+
);
19+
const input = screen.getByRole('textbox');
20+
21+
fireEvent.keyDown(input, {key: ' '});
22+
fireEvent.keyDown(input, {key: 'Escape'});
23+
input.focus();
24+
await user.keyboard('Rice');
25+
});
26+
it('handles selection of suggestion', () => {
27+
const onChange = jest.fn();
28+
29+
render(
30+
<FormInput
31+
label="label"
32+
inputProps={{value: 'on', onChange}}
33+
suggestions={['one', 'two', 'three']}
34+
/>
35+
);
36+
const input = screen.getByRole('textbox');
37+
38+
fireEvent.keyDown(input, {key: 'ArrowDown'});
39+
fireEvent.keyDown(input, {key: 'Enter'});
40+
});
41+
it('handles over-long list of suggestions', async () => {
42+
const suggestions = [];
43+
44+
for (let i=0; i < 420; ++i) {
45+
suggestions[i] = `item ${i}`;
46+
}
47+
render(
48+
<FormInput
49+
label="label"
50+
inputProps={{value: 'item'}}
51+
suggestions={suggestions}
52+
/>
53+
);
54+
const suggestion = screen.getByText('item 20');
55+
56+
// Mouse-interacts with suggestion items
57+
fireEvent.mouseMove(suggestion);
58+
await user.click(suggestion);
59+
});
60+
});

0 commit comments

Comments
 (0)