-
Notifications
You must be signed in to change notification settings - Fork 426
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Extract form field components into separate modules
Extract the `CharacterCounter`, `Label`, `TextField` and `Star` components into separate modules for future re-use and so they can have their own tests. In the process add a default value for the `type` prop and make `maxLength` optional so there are fewer required props for common use cases. Change the existing CreateEditGroupForm tests to operate on the exposed API of `TextField` rather than its implementation details.
- Loading branch information
1 parent
649506e
commit 1e280b6
Showing
6 changed files
with
279 additions
and
271 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
import Star from './Star'; | ||
|
||
/** | ||
* A label for a form field. | ||
* | ||
* This includes an indicator for whether the field is required or not. | ||
*/ | ||
export default function Label({ | ||
id, | ||
htmlFor, | ||
text, | ||
required, | ||
}: { | ||
id?: string; | ||
htmlFor?: string; | ||
text: string; | ||
required?: boolean; | ||
}) { | ||
return ( | ||
<label className="font-bold" id={id} htmlFor={htmlFor}> | ||
{text} | ||
{required && <Star />} | ||
</label> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
/** | ||
* A star icon denoting a required input field. | ||
*/ | ||
export default function Star() { | ||
return <span className="text-brand">*</span>; | ||
} |
124 changes: 124 additions & 0 deletions
124
h/static/scripts/group-forms/components/forms/TextField.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
import { useId, useState } from 'preact/hooks'; | ||
import { Input, Textarea } from '@hypothesis/frontend-shared'; | ||
|
||
import Label from './Label'; | ||
import Star from './Star'; | ||
|
||
function CharacterCounter({ | ||
value, | ||
limit, | ||
error = false, | ||
}: { | ||
value: number; | ||
limit: number; | ||
error?: boolean; | ||
}) { | ||
return ( | ||
<div className="flex"> | ||
<div className="grow" /> | ||
<span | ||
data-testid="char-counter" | ||
className={error ? 'text-red-error font-bold' : undefined} | ||
> | ||
{value}/{limit} | ||
</span> | ||
</div> | ||
); | ||
} | ||
|
||
export type TextFieldProps = { | ||
/** The DOM element to render. */ | ||
type?: 'input' | 'textarea'; | ||
|
||
/** Current value of the input. */ | ||
value: string; | ||
|
||
/** Callback invoked when the field's value is changed. */ | ||
onChangeValue: (newValue: string) => void; | ||
|
||
/** | ||
* Minimum number of characters that this field must have. | ||
* | ||
* This is a count of Unicode code points, not UTF-16 code units. | ||
*/ | ||
minLength?: number; | ||
|
||
/** | ||
* Maximum number of characters that may be entered. | ||
* | ||
* This is a count of Unicode code points, not UTF-16 code units. | ||
*/ | ||
maxLength?: number; | ||
|
||
/** Text for the label shown next to the field. */ | ||
label: string; | ||
|
||
/** True if this is a required field. */ | ||
required?: boolean; | ||
|
||
/** True if the field should be automatically focused on first render. */ | ||
autofocus?: boolean; | ||
|
||
/** Additional classes to apply to the input element. */ | ||
classes?: string; | ||
}; | ||
|
||
/** | ||
* A single or multi-line text field with an associated label and optional | ||
* character limit indicator. | ||
*/ | ||
export default function TextField({ | ||
type = 'input', | ||
value, | ||
onChangeValue, | ||
minLength = 0, | ||
maxLength, | ||
label, | ||
required = false, | ||
autofocus = false, | ||
classes = '', | ||
}: TextFieldProps) { | ||
const id = useId(); | ||
const [hasCommitted, setHasCommitted] = useState(false); | ||
|
||
const handleInput = (e: InputEvent) => { | ||
onChangeValue((e.target as HTMLInputElement).value); | ||
}; | ||
|
||
const handleChange = (e: Event) => { | ||
setHasCommitted(true); | ||
}; | ||
|
||
let error = ''; | ||
if (typeof maxLength === 'number' && [...value].length > maxLength) { | ||
error = `Must be ${maxLength} characters or less.`; | ||
} else if ([...value].length < minLength && hasCommitted) { | ||
error = `Must be ${minLength} characters or more.`; | ||
} | ||
|
||
const InputComponent = type === 'input' ? Input : Textarea; | ||
|
||
return ( | ||
<div className="mb-4"> | ||
<Label htmlFor={id} text={label} required={required} /> | ||
<InputComponent | ||
id={id} | ||
onInput={handleInput} | ||
onChange={handleChange} | ||
error={error} | ||
value={value} | ||
classes={classes} | ||
autofocus={autofocus} | ||
autocomplete="off" | ||
required={required} | ||
/> | ||
{typeof maxLength === 'number' && ( | ||
<CharacterCounter | ||
value={[...value].length} | ||
limit={maxLength} | ||
error={Boolean(error)} | ||
/> | ||
)} | ||
</div> | ||
); | ||
} |
81 changes: 81 additions & 0 deletions
81
h/static/scripts/group-forms/components/forms/test/TextField-test.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
import { mount } from 'enzyme'; | ||
|
||
import TextField from '../TextField'; | ||
|
||
function unicodeLen(value) { | ||
return [...value].length; | ||
} | ||
|
||
describe('TextField', () => { | ||
[ | ||
{ | ||
value: '', | ||
minLength: 0, | ||
maxLength: 10, | ||
error: null, | ||
}, | ||
{ | ||
value: 'abc', | ||
minLength: 0, | ||
maxLength: 10, | ||
error: null, | ||
}, | ||
// Too few characters | ||
{ | ||
value: 'a', | ||
minLength: 2, | ||
maxLength: 10, | ||
error: 'Must be 2 characters or more.', | ||
}, | ||
// Too many characters | ||
{ | ||
value: 'abc', | ||
minLength: 0, | ||
maxLength: 2, | ||
error: 'Must be 2 characters or less.', | ||
}, | ||
].forEach(({ value, minLength, maxLength, error }) => { | ||
it('displays character count and sets field error', () => { | ||
const wrapper = mount( | ||
<TextField value={value} minLength={minLength} maxLength={maxLength} />, | ||
); | ||
|
||
// The "too few characters" message is not shown until a change has | ||
// been committed in the field. | ||
wrapper.find('input').simulate('change'); | ||
|
||
const expectError = error !== null; | ||
|
||
const count = wrapper.find('[data-testid="char-counter"]'); | ||
const expectedText = `${unicodeLen(value)}/${maxLength}`; | ||
assert.equal(count.text(), expectedText); | ||
assert.equal(count.hasClass('text-red-error'), expectError); | ||
assert.equal(wrapper.find('Input').prop('error'), error ?? ''); | ||
}); | ||
}); | ||
|
||
it('invokes callback when text is entered', () => { | ||
const onChange = sinon.stub(); | ||
const wrapper = mount(<TextField value="" onChangeValue={onChange} />); | ||
|
||
wrapper.find('input').getDOMNode().value = 'foo'; | ||
wrapper.find('input').simulate('input'); | ||
|
||
assert.calledWith(onChange, 'foo'); | ||
}); | ||
|
||
it('defers checking for too few characters until first commit', () => { | ||
const wrapper = mount(<TextField value="" minLength={5} />); | ||
|
||
// Don't warn about too few characters before the user attempts to enter | ||
// an initial value. | ||
assert.equal(wrapper.find('Input').prop('error'), ''); | ||
|
||
wrapper.find('input').simulate('change'); | ||
|
||
assert.equal( | ||
wrapper.find('Input').prop('error'), | ||
'Must be 5 characters or more.', | ||
); | ||
}); | ||
}); |
Oops, something went wrong.