fix(cuesheet): restore keyboard focus flow for lazy text cells

The lazy editor (Exp A) unmounts on exit, so the editor's own deferred
focus-return (useReactiveTextInput) targeted a detached node and focus was
lost to <body>, breaking table keyboard navigation after submit/escape.

Return focus to the parent cell deterministically in a layout effect once the
display element is back in the DOM (rather than a setTimeout that races the
next keypress), mirroring the time/duration cells.

Update the e2e tests to the lazy interaction model (click the cell to open the
on-demand editor, assert it unmounts on exit) — matching the existing timer
cell test. Verified end-to-end: 202-cuesheet (3/3) and 206-url-preset (5/5).
This commit is contained in:
Claude
2026-06-19 06:23:51 +00:00
parent d17a7e9321
commit dcdfd9ca85
3 changed files with 49 additions and 25 deletions
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { memo, useCallback, useLayoutEffect, useRef, useState } from 'react';
import MultiLineCell from './MultiLineCell';
import SingleLineCell from './SingleLineCell';
@@ -17,6 +17,10 @@ interface FocusableEditor {
select?: () => void;
}
interface FocusableDisplay {
focusParentElement: () => void;
}
/**
* Lazily mounts the text editor for a cell.
*
@@ -24,20 +28,31 @@ interface FocusableEditor {
* cell is expensive when many rows mount at once during virtualised scroll. While the cell is not
* being edited we render a lightweight, focusable display element and only mount the real editor
* when the user clicks/focuses the cell — mirroring how the time/duration cells already behave.
*
* On exit we return focus to the parent cell (through the display element, in a layout effect once
* it is back in the DOM) so the table keyboard navigation keeps working — the editor is unmounted
* by then, so we cannot rely on its own ref.
*/
function EditableCell({ initialValue, multiline, fieldId, fieldLabel, handleUpdate }: EditableCellProps) {
const [isEditing, setIsEditing] = useState(false);
const wasEditing = useRef(false);
const editorRef = useRef<FocusableEditor | null>(null);
const displayRef = useRef<FocusableDisplay | null>(null);
// focus the editor once it mounts on entering edit mode
useEffect(() => {
useLayoutEffect(() => {
if (isEditing) {
// focus the editor once it mounts on entering edit mode
editorRef.current?.focus();
editorRef.current?.select?.();
} else if (wasEditing.current) {
// returning from edit: hand focus back to the cell so table keyboard navigation continues
displayRef.current?.focusParentElement();
}
wasEditing.current = isEditing;
}, [isEditing]);
const enterEdit = useCallback(() => setIsEditing(true), []);
const exitEdit = useCallback(() => setIsEditing(false), []);
const onSubmit = useCallback(
(newValue: string) => {
@@ -47,11 +62,10 @@ function EditableCell({ initialValue, multiline, fieldId, fieldLabel, handleUpda
[handleUpdate],
);
const onCancel = useCallback(() => setIsEditing(false), []);
if (!isEditing) {
return (
<TextLikeInput
ref={displayRef}
onClick={enterEdit}
onFocus={enterEdit}
multiline={multiline}
@@ -69,7 +83,7 @@ function EditableCell({ initialValue, multiline, fieldId, fieldLabel, handleUpda
fieldId={fieldId}
fieldLabel={fieldLabel}
handleUpdate={onSubmit}
handleCancelUpdate={onCancel}
handleCancelUpdate={exitEdit}
/>
) : (
<SingleLineCell
@@ -78,9 +92,10 @@ function EditableCell({ initialValue, multiline, fieldId, fieldLabel, handleUpda
fieldId={fieldId}
fieldLabel={fieldLabel}
handleUpdate={onSubmit}
handleCancelUpdate={onCancel}
handleCancelUpdate={exitEdit}
/>
);
}
export default memo(EditableCell);