This post was originally published on my blog
Forms are an important part of website and app development. They are critical elements in terms of UX and accessibility because they are the parts that users actually interact with. Poorly designed forms are not only annoying for everyone, but if they are not properly marked up, they may also hinder the use of assistive technologies.
However, the implementation of a user-friendly form is complex, and if it includes validation by JavaScript, it can be burdensome for developers. In this article, I would like to introduce the implementation of a React component called FormControl
that helps us appropriately markup the form fields.
The implementation in this article is largely based on MUI - FormControl.
Usage
If you want to use all options:
return (
<FormControl labelText="Name" helperText="Tell us your name" error="Something is wrong">
<TextInput required placeholder="John Doe" />
</FormControl>
)
The following is the output HTML. The id
is automatically published within the component and is properly linked to the label
element. In addition, aria attributes such as aria-describedby
are also assigned.
<div class="FormControl">
<label for=":r1:" id=":r1:--label">Name</label>
<div id=":r1:--helper-text">Tell us your name</div>
<input
id=":r1:"
aria-describedby=":r1:--helper-text :r1:--error-text"
aria-invalid="true"
placeholder="John Doe"
/>
<div id=":r1:--error-text">Something is wrong</div>
</div>
Here is a style example. We will not touch on CSS much in this article, but you can pass the error
prop to add a class name, or use the &:has([required])
selector to display an asterisk for required items as a pseudo-element.
Firefox doesn't support
:has()
yet.
Implementation method
Approach
As a premise, it is required that the user input component can be flexibly passed to the TextInput
part mentioned above. Not all form fields are input
, so it should be loosely coupled.
This time, we will use the Context
of React as an interface. FormControl
will add appropriate markup to the user input element received as children, wrap it with Context.Provider
, and pass data such as the automatically generated id
. This allows the child element to consume the data by calling useContext
.
Define Context
Let's define FormControlContextValue
as follows:
import React from "react"
export type FormControlContextValue = {
id: string | undefined
labelId: string | undefined
helperTextId: string | undefined
errorTextId: string | undefined
isError: boolean | string
}
export const FormControlContext = React.createContext<FormControlContextValue>({
id: undefined,
labelId: undefined,
helperTextId: undefined,
errorTextId: undefined,
isError: false,
})
export function useFormControlContext() {
return React.useContext(FormControlContext)
}
Implement FormControl
We generate a single id
to pass to the input element using React.useId
and add a suffix to other IDs. Other than being surrounded by FormControlContext.Provider
, nothing is special.
import React from "react"
import { FormControlContext } from "./FormControlContext"
export type FormControlProps = {
labelText: string
helperText?: string
error?: boolean | string
children: React.ReactNode
}
export function FormControl({
labelText,
helperText: helperText,
error = false,
children,
}: FormControlProps) {
const id = React.useId()
const labelId = `${id}--label`
const helperTextId = `${id}--helper-text`
const errorTextId = `${id}--error-text`
const ctxValue = {
id,
labelId,
helperTextId,
errorTextId,
isError: !!error,
}
return (
<FormControlContext.Provider value={ctxValue}>
<div>
<label htmlFor={id} id={labelId}>
{labelText}
</label>
{helperText && <span id={helperText}>{helperText}</span>}
{children}
{typeof error === "string" && <span id={errorTextId}>{error}</span>}
</div>
</FormControlContext.Provider>
)
}
Implement Input
We will first implement the generic Input
component, rather than the TextInput
component in the first example. By calling useFormControlContext()
, you can use the values provided by the enclosing FormControl
.
-
aria-describedby
can specify multiple IDs separated by spaces, so we pass both supplementary explanatory text and error messages. -
isError
passed toaria-invalid
becomestrue
whenerror
is specified with a truthy value (true or a non-empty string). You can style erroneous state with CSS selectors likeinput[aria-invalid="true"]
.
import React from "react"
import { useFormControlContext } from "../FormControl/useFormControlContext"
type Props = JSX.IntrinsicElements["input"]
export const Input = React.forwardRef<HTMLInputElement, Props>((props, forwardedRef) => {
const { id, helperTextId, errorTextId, isError } = useFormControlContext()
return (
<input
id={id}
aria-describedby={[helperTextId || "", errorTextId || ""].join(" ")}
aria-invalid={isError}
ref={forwardedRef}
{...props}
/>
)
})
Input.displayName = "Input"
In this implementation, extra spaces are added to
aria-describedby
, so it may be better to do some filtering.
Extend Input
You can use the Input
component as is, or create extended components such as TextInput
or Checkbox
by specifying the type
. It should be easy to integrate with external libraries by passing them id
.
Other considerations
What if nested?
Since useContext
refers to the value of the nearest Provider, there is no problem even if FormControl
is nested. Therefore, it can be used as follows.
export function RadioGroup({ children }: RadioGroupProps) {
const { labelId } = useFormControlContext()
return (
<div role="radiogroup" aria-labeledby={labelId}>
{children}
</div>
)
}
return (
<FormControl labelText="PPAP">
<RadioGroup>
<FormControl labelText="Pen">
<Input type="radio" name="ppap" value="pen" />
</FormControl>
<FormControl labelText="Pineapple">
<Input type="radio" name="ppap" value="pineapple" />
</FormControl>
<FormControl labelText="Apple">
<Input type="radio" name="ppap" value="apple" />
</FormControl>
</RadioGroup>
</FormControl>
)
Is it bad idea to enclose input
by label
?
Although it is certainly convenient to eliminate the management of IDs by putting input
inside label
, I think it is better to link with htmlFor
if you want to support aria-describedby
and behavior when nested. If those are not needed, it is also fine to simply surround it.
What are the other implementation methods besides Context?
It may be convenient to also support render props.
export type FormControlProps = {
{...},
children:
| React.ReactNode
| ((props: FormControlContextValue) => React.ReactNode);
};
function FormControl() {
{...}
return (
{...}
{typeof children === "function" ? children(ctxValue) : children}
{...}
)
}
function Component() {
return (
<FormControl labelText={labelText}>
{({ id, helperTextId }) => (
<input id={id} aria-describedby={helperTextId} />
)}
</FormControl>
)
}
TODO
I think the FormControl
implementation we did this time can solve most markup problems, but there are still things left to do such as styling and validation.
Styling
Not all labels and supplementary text will be displayed in the same place or appearance. For example, you should probably place radio buttons horizontally with the label. The appearance of FormControl
should be able to be flexibly set from the outside.
Validation
Since FormControl
does not do anything more than helping markup, I think it is quite compatible with React Hook Form. If you want to add client-side behavior, it is probably easier to separate responsibilities by overlaying it as a higher layer (rather than modifying FormControl
).
Top comments (0)