React & Drupal: Editable fields for inline content editing (on display mode)
Edit and View modes
When creating any React component, keep accessibility in mind.
Your component should work with only a keyboard,
Your component should use the correct HTML elements and other attributes to provide the most context to users.
One way to approach writing an inline edit component is to have two separate components.
One for a “view mode” and one for a “edit mode”:
// View mode
<div onClick={startEditing}>Text value</div>
// Edit mode
<input value="Text value" />
When a user clicks on the view mode component, it will disappear and the edit mode will appear.
The second approach (and the one we will be implementing below) is to always use an input element.
We can use CSS to make it look as though it has begun editing when a user focuses on it.
// View and edit mode
<input value="Text value" />
By always using an input element, we get behaviours like tabbing and focusing for free.
It also makes more explicit what the purpose of the component is.
Let’s get started by creating a React component that uses the HTML input tag:
const InlineEdit = ({ value, setValue }) => {
const onChange = (event) => setValue(event.target.value);
return (
<input
type="text"
aria-label="Field name"
value={value}
onChange={onChange}
/>
)
}
The aria-label tells screen reader users the purpose of the input.
For instance, if it was the name of a list, you could use "List name".
Then, let's render our new InlineEdit component, and pass in a value and setValue props:
const App = () => {
const [value, setValue] = useState();
return <InlineEdit value={value} setValue={setValue} />;
}
In a real-life app, the setValue function would make an endpoint call to store the value in a database somewhere.
For this tutorial though, we'll store the value in a useState hook.
Add CSS to make it "click to edit"
We’ll then add some CSS to remove the input styling. This makes it look as though the user needs to click or focus on the input to start editing.
input {
background-color: transparent;
border: 0;
padding: 8px;
}
input:hover {
background-color: #d3d3d3;
cursor: pointer;
}
Allow users to save when they press Enter or Escape
If a user clicks away from the input, it will lose focus and return to “view” mode.
To keep things keyboard-friendly, we’ll want the escape and enter keys to achieve the same affect.
const InlineEdit = ({ value, setValue }) => {
const onChange = (event) => setValue(event.target.value);
const onKeyDown = (event) => {
if (event.key === "Enter" || event.key === "Escape") {
event.target.blur();
}
}
return (
<input
type="text"
aria-label="Field name"
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
/>
)
}
Only save on exit
Currently we call the setValue prop on each key press.
In a real-life situation, where setValue was making an endpoint call, it would be making an endpoint call per keypress.
We want to prevent this from happening until a user exits the input.
here I created a local state variable called editingValue.
This is where we’ll store the value of the input when it is in a “editing” phase.
A user exiting the input will call the onBlur handler. So we can use this to call setValue.
const InlineEdit = ({ value, setValue }) => {
const [editingValue, setEditingValue] = useState(value);
const onChange = (event) => setEditingValue(event.target.value);
const onKeyDown = (event) => {
if (event.key === "Enter" || event.key === "Escape") {
event.target.blur();
}
}
const onBlur = (event) => {
setValue(event.target.value)
}
return (
<input
type="text"
aria-label="Field name"
value={editingValue}
onChange={onChange}
onKeyDown={onKeyDown}
onBlur={onBlur}
/>
)
}
Adding validation on empty strings
Finally, you don’t want users to be able to save an empty string or spaces as a value.
In that case, we’ll cancel the edit and use the original value.
You'll now have a complete single-line inline edit component.
import { useState } from 'react';
const InlineEdit = ({ value, setValue }) => {
const [editingValue, setEditingValue] = useState(value);
const onChange = (event) => setEditingValue(event.target.value);
const onKeyDown = (event) => {
if (event.key === "Enter" || event.key === "Escape") {
event.target.blur();
}
}
const onBlur = (event) => {
if (event.target.value.trim() === "") {
setEditingValue(value);
} else {
setValue(event.target.value)
}
}
return (
<input
type="text"
aria-label="Field name"
value={editingValue}
onChange={onChange}
onKeyDown={onKeyDown}
onBlur={onBlur}
/>
);
};
const App = () => {
const [value, setValue] = useState();
return <InlineEdit value={value} setValue={setValue} />;
};
Creating a multiline inline edit
If you want your inline edit component to be multiline, we can use the textarea element instead.
The one difference with textarea is that you pass in a rows value.
This specifies the height of your textarea.
By default, textareas aren't dynamic. Luckily, over on StackOverflow I found a solution to this problem.
If you add the following CSS to your text area.
<textarea
rows={1}
aria-label="Field name"
value={editingValue}
onBlur={onBlur}
onChange={onChange}
onKeyDown={onKeyDown}
/>
textarea {
resize: none;
overflow: hidden;
min-height: 14px;
max-height: 100px;
}
And then pass in an onInput handler, you’ll be able to achieve a “dynamic” look.
import { useEffect } from 'react';
const onInput = (event) => {
if (event.target.scrollHeight > 33) {
event.target.style.height = "5px";
event.target.style.height = (event.target.scrollHeight - 16) + "px";
}
}
return (
<textarea
rows={1}
aria-label="Field name"
value={editingValue}
onBlur={onBlur}
onChange={onChange}
onKeyDown={onKeyDown}
onInput={onInput}
/>
)
Note you may need to fiddle around with some of the values in the onInput depending on the height and font size of your text area.
The one other thing you’ll need to add is a focus ring - the blue outline around a focused element.
We can do this with some CSS:
textarea:focus {
outline: 5px auto Highlight; /* Firefox */
outline: 5px auto -webkit-focus-ring-color; /* Chrome, Safari */
}
Full code look like the following
import { useState, useRef } from 'react';
const MultilineEdit = ({ value, setValue }) => {
const [editingValue, setEditingValue] = useState(value);
const onChange = (event) => setEditingValue(event.target.value);
const onKeyDown = (event) => {
if (event.key === "Enter" || event.key === "Escape") {
event.target.blur();
}
};
const onBlur = (event) => {
if (event.target.value.trim() === "") {
setEditingValue(value);
} else {
setValue(event.target.value);
}
};
const onInput = (target) => {
if (target.scrollHeight > 33) {
target.style.height = "5px";
target.style.height = target.scrollHeight - 16 + "px";
}
};
const textareaRef = useRef();
useEffect(() => {
onInput(textareaRef.current);
}, [onInput, textareaRef]);
return (
<textarea
rows={1}
aria-label="Field name"
value={editingValue}
onBlur={onBlur}
onChange={onChange}
onKeyDown={onKeyDown}
onInput={(event) => onInput(event.target)}
ref={textareaRef}
/>
);
};