I'm building out a system in React that has tabular data with cells. Those cells are editable via contentEditable divs. It's functionally similar to google sheets. I'm working on the functionality where single click on the cell allows the user to override the current value of the cell and double clicking allows them to edit the value.
The functionality involved is basically this:
- When single click on cell override the current value. (No cursor visible?)
- When double click on cell allow the user to edit the current value. (Cursor visible, can move left and right of chars with arrowKeys)
- When double clicked into the cell reformat value (removes trailing zero's for cents: 8.50 becomes 8.5)
- When double clicked start the caret position at the end of the input.
- When user clicks out of the cells reformat the current value to its appropriate format (example is a price cell)
My cell component looks like this:
(Note* useDoubleClick() is a custom hook I wrote that works perfectly fine and will call single/double click action accordingly)
export default function Cell({ name, value, updateItem }) {
const [value, setValue] = useState(props.value), // This stays uncontrolled to prevent the caret jumps with content editable.
[isInputMode, setIsInputMode] = useState(false),
cellRef = useRef(null);
// Handle single click. Basically does nothing right now.
const singleClickAction = () => {
if(isInputMode)
return;
}
// Handle double click.
const doubleClickAction = () => {
// If already input mode, do nothing.
if(isInputMode) {
return;
}
setIsInputMode(true);
setCaretPosition(); // Crashing page sometimes [see error below]
reformatValue();
}
// It's now input mode, set the caret position to the length of the cell's innerText.
const setCaretPosition = () => {
var range = document.createRange(),
select = window.getSelection();
range.setStart(cellRef.current.childNodes[0], cellRef.current.innerText.length);
range.collapse(true);
selectObject.removeAllRanges();
selectObject.addRange(range);
}
// Reformat innerText value to remove trailing zero's from cents.
const reformatValue = () => {
var updatedValue = parseFloat(value);
setValue(updatedValue);
}
const onClick = useDoubleClick(singleClickAction, doubleClickAction);
/*
* Handle input change. Pass innerText value to global update function.
* Because we are using contentEditable and render "" if !isInputMode
* we have override functionality.
*/
const onInput = (e) => {
props.updateItem(props.name, e.target.innerText);
}
// When cell is blurred, reset isInputMode
const onBlur = () => {
setIsInputMode(false);
cellRef.current.innerText = ""; // Without this single click will not override again after blur.
}
return (
<div
data-placeholder={value} // to view current value while isInputMode is false
class="cell-input"
contentEditable="true"
onClick={onClick}
onInput={onInput}
onBlur={onBlur}
ref={cellRef}
>
{isInputMode ? value : ""}
</div>
)
}
And here is some css so that the user can see the current value while isInputMode is false:
.cell-input {
:empty:before {
content: attr(data-placeholder);
}
:empty:focus:before {
content: attr(data-placeholder);
}
}
Now here are the issues I'm running into.
- When I call the setCaretPosition function, there are no childNodes because I'm rendering the empty value ("") and crashes the page sometimes with the error- TypeError: Argument 1 ('node') to Range.setStart must be an instance of Node.
- I have a $ inside cells that contain a price and I was setting that in the css with ::before and content: '$', but now I can't because of the data-placeholder snippet.
- When double clicking into cell the cursor is not visible at all. If you click the arrows to move between characters it then becomes visible.
This solution has me pretty close to my desired output so I feel pretty good about it, but I think there might be a better way to go about it or a few tweaks within my solution that will be a general improvement. Would love to hear some ideas.