Accessible text field component with React and TypeScript
Tags: a11y, css, react, javascript
Published: 2023-02-01 06:42:00

---
title: "Accessible text field component with React and TypeScript"
published: true
description: "How to build an accessible text field component in React and TypeScript"
published_at: 2023-02-01 06:42 +0000
cover_image: https://res.cloudinary.com/mayashavin/image/upload/articles/8
tags: ['Accessibility', 'CSS', 'React', 'JavaScript']
canonical_url: https://mayashavin.com/articles/build-accessibile-editable-text-field
series: Building components with accessibility
---

Writing accessible components, as many developers believe, is a challenging task. However, with the appropriate structure and component design, you can achieve accessibility quickly, starting from the essential ones.

In this article, we are going to implement a `TextField` component using React and TypeScript, which has the following features:
1. A `label` and an `input` element, 
2. An Edit `button` next to the label controls the edit mode of the input. We call this the self-edit mode.
3. Clicking on the Edit `button` will change the `input` to editable and auto-focus on the input field. Once done editing, the focus returns to the previously focused element - the Edit button.
4. When it is in edit mode of self-edit, you can cancel or save the edited value.
5. It has to be accessible, with screen reader support and keyboard navigation.

An example UI design of this component is as below:

<img src="https://res.cloudinary.com/mayashavin/image/upload/f_auto,q_auto/v1675152091/TextField" loading="lazy" alt="Text field component design" class="mx-auto"/>

Let's start by creating the component, shall we?

## Creating TextField component

From the mockup above, we will start with the basic implementation of the component, including an `input` field and a `label`.

```javascript
export function TextField () {
 return (
  <div className="textfield--wrapper">
    <div className="textfield--header">
       <label></label>
    </div>
   <input />
  </div>
 )
}
```

Since we want this `TextField` component to behave similarly to an `input` field, we will make sure it receives the following props:

1. `id` - the unique id we use to identify the `input` field
2. `label` - the label text to display
3. `placeholder` - the placeholder for the input. It should be _optional_.
4. Any other input's good attributes and events.

The above requirements indicate our component props' interface will look like the following:

```typescript
import { InputHTMLAttributes } from "react";

interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
    label: string;
}
```

In the above TypeScript code, our `TextFieldProps` inherits the attributes defined for `HTMLInputElement`, with an additional `string` field - `label`. Now let's map our props to the designated places, such as `label`, `id`, and other input props. We need to make sure `label` describes our `input` field by using `htmlFor` (instead of `for` in plain HTML), as follows:

```javascript
export function TextField ({ id, label, ...props }: TextFieldProps) {
 return (
  <div className="textfield--wrapper">
    <div className="textfield--header">
        <label htmlFor={id}>{label}</label>
    </div>
   <input id={id} {...props} className="textfield--input"/>
  </div>
 )
}
```

And we also added a bit of CSS to make our component looks organized:

```css
.textfield--wrapper {
    display: flex;
    flex-direction: column;
    gap: 5px;
}

.textfield--header {
    display: flex;
    align-items: center;
    justify-content: space-between;
}

.textfield--input {
    padding: 10px;
    border-radius: 5px;
    font-size: 1rem;
}
```

Our TextField component is ready for primary usage with the above CSS code as an input field. Next, we will add the edit mode to control the input field's editable mode.

## Adding the self-edit control mode

First, we will create a local state `editMode` using `useState`, which receives an initial value `false`, as follows:

```javascript
const [editMode, setEditMode] = useState(false)
```

We then use this `editMode` state to control whether we want to allow our `input` to be `read-only`.

```html
<input id={id} {...props} readOnly={!editMode} className="textfield--input" />
```

Now let's add some button actions to allow the user to control the state `editMode`, shall we?

### Controlling the edit mode of `TextField` component

From our initial design, to control the `editMode` from the client side, we need to display the following actions:
* Edit - turn on the edit mode, and is visible when `editMode` is `false`.
* Cancel - visible when `editMode` is `true` and allows the user to return to read-only mode without saving.
* Save - visible when `editMode` is `false`. It allows the user to trigger an external `onSave` function and save the new edited text value. 

Sounds pretty straightforward enough. We modify the header of the `TextField` component to include three action buttons, with Save/Cancel appearing only when `editMode` is `true`, and Edit otherwise:

```html
    <div className="textfield--header">
        <label htmlFor={id}>{label}</label>
        { editMode ? (
            <div className="textfield--header-actions">
                <button className="textfield--header-action">Cancel</button>
                <button className="textfield--header-action">Save</button>
            </div>
        ) : (     
            <button className="textfield--header-action">Edit</button>
        )}
    </div>
```

We now toggle `editMode` whenever the user clicks on the Edit button and on the Save/Cancel button, respectively, by defining the following functions:

```typescript
const closeEditMode = () => {
    setEditMode(false)
}

const openEditMode = () => {
    setEditMode(true)
}

const onEditHandler = () => {
    closeEditMode();
}
```

And then we bind those functions to the related buttons' `onClick` events, as follows:

```html
{ editMode ? (
    <div className="textfield--header-actions">
        <button onClick={closeEditMode} className="textfield--header-action">Cancel</button>
        <button onClick={onEditHandler} className="textfield--header-action">Save</button>
    </div>
) : (     
    <button onClick={openEditMode}>Edit</button>
)}
```

Our `TextField` component now behaves as follows:

<img src="https://res.cloudinary.com/mayashavin/video/upload/q_auto/v1675152077/textfield_toggle.gif" loading="lazy" alt="Toggling the edit mode of the input field using Edit button" class="mx-auto"/>

The following section will explore a more advanced accessible support feature - focus-ability.

## Where is my focus?

Accessibility is not only about screen readers. It is also about providing navigation support to the user, in which focus plays an important role. Focus allows the user with any alternative navigation (such as a keyboard) to locate their location on the page, helping them navigate a complex page.

At this stage, we add the feature to toggle the edit mode of the input. We will ensure when the edit mode is on, the browser will automatically focus on the input field. And when the edit mode is off, the browser will return the focus to the Edit button.

### Focusing on the input when switching to edit mode

To change the focus from the button to the input field when edit mode is on, we use the `useRef()` and `useEffect()` hooks.

First, we will create a reference variable `inputRef` and assign it to the `ref` attribute of the `input` field, as follows:

```javascript
const inputRef = useRef<HTMLInputElement>(null);

//other logic

return (
    <div className="textfield--wrapper">
        <div className="textfield--header">
            <!--...-->
        </div>
       <input id={id} {...props} readOnly={!editMode} ref={inputRef} className="textfield--input"/>
    </div>
)
```

Next, we use `useEffect()` to focus on the `input` element whenever `editMode` is `true`.

```javascript
useEffect(() => {
    if (!editMode) return;

    inputRef?.current?.focus()
}, [editMode])
```

That's it. Now we have the input focused when the user switches to edit mode. 

<img src="https://res.cloudinary.com/mayashavin/video/upload/q_auto/v1675152077/input_focus_only.gif" loading="lazy" alt="Input field is automatically focused when Edit button is clicked" class="mx-auto"/>

However, we still have one problem. When you click the Save button, notice that you will lose focus on the Edit/Save button after component updates. Behind the scenes, React destroyed the previous `button` element for Save and replaced it with a different `button` element, thus caused the missing focus.

At this point, the browser loses track of the currently focused element (since it is no longer there) and automatically returns the focus to the main window. This behavior occurs since the focus is no longer on the last focused element (the last button element no longer exists). Users won't notice the difference in the UI, but when they try to continue navigating using the keyboard, they will need help understanding where their focused element is.

We will solve this challenge next.

### Returning the focus to the Edit button

To solve this returning focus challenge, we first modify the component's HTML structure for the actions to be the following:

```html
<div className="textfield--header-actions">
{editMode && (
    <button onClick={closeEditMode} className="textfield--header-action">Cancel</button>
)}
    <button 
        onClick={editMode ? onEditHandler : openEditMode} 
        className="textfield--header-action"
    >
        {editMode ? 'Save' : 'Edit'}
    </button>
</div>
```

By doing so, whenever we click on Save, the browser will maintain the focus on the Edit button. Since the button is the same, its label and bind `onClick` action changed.

However, we still need to return the focus to Edit when clicking the Cancel button. For such scenarios, we keep a reference to the Edit button by using `useRef`, as follows:

```javascript
const editBtnRef = useRef<HTMLButtonElement>(null)
```

Then we attach it to the target button, as in the below code:

```html
<div>
{editMode && (
    <button onClick={closeEditMode} className="textfield--header-action">Cancel</button>
)}
    <button 
        className="textfield--header-action"
        ref={editBtnRef} 
        onClick={editMode ? onEditHandler : openEditMode}>
        {editMode ? 'Save' : 'Edit'}
    </button>
</div>
```

In the `closeEditMode` function, we will re-focus on the target button if `editMode` is `false`, as in the following:


```javascript
const closeEditMode = () => {
    setEditMode(false);
    editBtnRef?.current?.focus();
}
```

Great. Now our component is enabled with full focus mode supported:

<img src="https://res.cloudinary.com/mayashavin/video/upload/q_auto/v1675152077/focus_back_button.gif" loading="lazy" alt="Clicking on Save or Cancel returned focus back to Edit" class="mx-auto"/>

Next, we will modify our buttons to use icons instead and explore how to ensure our component is fully accessible for visibility and screen readers.

## Making icon buttons accessible using ARIA attributes

To make our TextField look like the design mockup, we will replace the Save/Edit/Cancel buttons with the following icons - `SaveIcon`, `EditIcon`, and `CancelIcon` components, respectively. Each of those is an SVG React component (you can download them via [Icones](https://icones.netlify.app/collection/all?s=edit), but you can use any other icon generators.

```html
{editMode && (
    <button onClick={closeEditMode} className="textfield--header-action">
        <CloseIcon  />
    </button>
)}
<button
    className="textfield--header-action"
    ref={editBtnRef} 
    onClick={editMode ? onEditHandler : openEditMode}
>                  
    {editMode ? <SaveIcon /> : <EditIcon />}
</button>
```

Since the screen reader won't be able to read the icon button due to the lack of a descriptive label, we use `aria-label` to provide a readable label of the icon button for screen readers, in addition to a built-in attribute `title`, as shown below:

```html
{editMode && (
    <button 
        className="textfield--header-action"
        onClick={closeEditMode} 
        aria-label="Cancel"  
        title="Cancel"
    >
        <CloseIcon  />
    </button>
)}
<button aria-label="Edit" title="Edit"
    className="textfield--header-action"
    ref={editBtnRef} 
    onClick={editMode ? onEditHandler : openEditMode}
    aria-label={editMode ? 'Save' : 'Edit'}
>                  
    {editMode ? <SaveIcon /> : <EditIcon />}
</button>

```

Unlike some ARIA attributes, the `title` attribute is HTML-native and supported by all browsers. You need `title` to display a short description tooltip of the target button when hovering, making it easier for the user to understand what the button is about. We also need to set `aria-hidden` to `true` for the icon, so the screen reader will skip reading the icon, as shown below:

```html
{editMode && (
    <button 
        className="textfield--header-action"
        onClick={closeEditMode} 
        aria-label="Cancel"  
        title="Cancel"
    >
        <CloseIcon aria-hidden="true" />
    </button>
)}
<button 
    className="textfield--header-action"
    ref={editBtnRef} 
    onClick={editMode ? closeEditMode : openEditMode}
    aria-label={editMode ? 'Save' : 'Edit'}
    title={editMode ? 'Save' : 'Edit'}
>                  
    {editMode ? (
        <SaveIcon aria-hidden="true" />
    ) : (
        <EditIcon aria-hidden="true" />
    )}
</button>

```

The following screenshot describes how the screen reader picks up the buttons.

<video controls="" autoplay="" name="media">
<source src="https://res.cloudinary.com/mayashavin/video/upload/q_auto,f_auto/v1675154060/navigation_textfield.mp4" type="video/mp4">
</video>

From the screenshot above, notice that on keyboard focus for any of the buttons, there is no tooltip, unlike in hover. Without any tooltip or title, some users may find it hard to understand what the button is for. One approach to handling such cases is using the pseudo element `after` of the class `textfield--header-action` when the target element is on `focus`:

```css
.textfield--header-action[title]:focus::after {
    content: attr(title);
    background-color: black;
    color: white;
    padding: 5px;
    margin-top: -2.3em;
    position: absolute;
    max-width: 200px;
    border-radius: 5px;
    z-index: 1;
}
```

In the above code, we take the attribute `title` value using `attr()`, assign it to the `content` property, and add other CSS rules to position and beautify the tooltip. Now when in focus mode, our buttons will have a small tooltip, as shown in the following screenshot:

<img src="https://res.cloudinary.com/mayashavin/image/upload/q_auto,f_auto/v1675154180/tooltip_on_focus" loading="lazy" alt="A small tooltip with text of edit appears when focusing on Edit button" class="mx-auto"/>

At this stage, our component is accessible, but not enough. Since we control the input's editable mode outside of these actions, we must also reflect that on the screen readers, which we will explore next.

### Indicating the control element for the input field using `aria-controls`

We should use `aria-controls` to indicate the control of these buttons towards the visibility/state of the input field by its `id`, and complete the user experience as follows:

```html
{editMode && (
    <button 
        className="textfield--header-action"
        onClick={closeEditMode} 
        aria-label="Cancel" 
        title="Cancel" 
        aria-controls={id}
    >
        <CloseIcon aria-hidden="true" />
    </button>
)}
    <button 
        ref={editBtnRef} 
        onClick={editMode ? onEditHandler : openEditMode}
        aria-label={editMode ? 'Save' : 'Edit'}
        title={editMode ? 'Save' : 'Edit'}
        className="textfield--header-action"
        aria-controls={id}
    >                  
        {editMode ? (
            <SaveIcon aria-hidden="true" />
        ) : (
            <EditIcon aria-hidden="true" />
        )}
    </button>
```

And the screen reader will pick up the desired information and announce it to our users accordingly. 

Lastly, we will ensure the component receives and binds the external onSave event for the related actions.

## Saving your input changes

To enable updating the input's values externally, we can modify the `TextFieldProps` to accept an `onSave` asynchronous function, which receives input value, and returns a `Promise`, as follows:

```typescript
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
    label: String;
    onSave?: (value: any) => Promise<void>
}
```

Then we modify `onEditHandler` to trigger `onSave` with the current value of the `input`, as shown in the following code:

```typescript
const onEditHandler = async () => {
    const currentValue = inputRef?.current?.value;

    const onSavePromise = await onSave?.(currentValue);

    closeEditMode();

    return onSavePromise;
}
```

That's it. Our component is complete and will render the following UI for non-edit mode:

<img src="https://res.cloudinary.com/mayashavin/image/upload/q_auto,f_auto/v1675152077/non_edit_mode" loading="lazy" alt="How VoiceOver reads through the navigation of TextField using tab" class="mx-auto"/>

And for edit mode:

<img src="https://res.cloudinary.com/mayashavin/image/upload/q_auto,f_auto/v1675152077/edit_mode" loading="lazy" alt="How VoiceOver reads through the navigation of TextField using tab" class="mx-auto"/>

One additional recommendation is to create a component that renders a hidden screen reader text and use it with `aria-describedby` to provide more descriptive context besides the regular `arial-label`.

Furthermore, you can add `onCancel` or any other props to allow more customizations to the component or modify the implementation to match your look and feel, as well as add further props to the label such as asterisk icon for required field's label, etc..  Let your imagination go wild ;)

You can find the complete working code [here](https://github.com/mayashavin/react-accessible-components/blob/main/src/components/TextField.tsx).

## Summary

Building an accessible TextField component is not as complex as most of us may think. Nevertheless, making it accessible requires effort to play around with screen readers and keyboard navigation to fully understand how other users may feel when using your application. 

The TextField component in this article is uncontrollable. It has its state for editing. If we want to use it in a form where we need to control the edit mode externally, we have to modify the component's logic slightly. The following article will explore how to turn the element into a controllable and accessible TextField.

👉 _If you'd like to catch up with me sometimes, follow me on [Twitter](https://twitter.com/MayaShavin) | [Facebook](https://www.facebook.com/mayashavin/) | [GitHub](https://github.com/mayashavin)._

Like this post or find it helpful? Share it 👇🏼 😉