fix(cuesheet): prevent timer cells from submitting on tab-out or escape (#2083)

This commit is contained in:
Carlos Valente
2026-06-07 12:59:18 +02:00
committed by GitHub
parent 5a3ea9f7d0
commit 199dea5df6
5 changed files with 98 additions and 7 deletions
@@ -3,8 +3,8 @@ import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo,
interface UseReactiveTextInputReturn {
value: string;
onChange: (event: ChangeEvent) => void;
onBlur: (event: ChangeEvent) => void;
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onBlur: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
}
@@ -15,7 +15,9 @@ export default function useReactiveTextInput(
options?: {
submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean;
submitOnTab?: boolean;
onCancelUpdate?: () => void;
onTabCancel?: () => void;
allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean;
},
@@ -23,6 +25,10 @@ export default function useReactiveTextInput(
const [text, setText] = useState<string>(initialText);
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
const isKeyboardSubmitting = useRef(false);
// track escape to prevent the subsequent blur from submitting
const isEscaping = useRef(false);
// track tab-out to prevent blur from submitting (used by timer cells)
const isTabbing = useRef(false);
useEffect(() => {
if (typeof initialText === 'undefined') {
@@ -78,6 +84,7 @@ export default function useReactiveTextInput(
* @param {string} valueToSubmit
*/
const handleEscape = useCallback(() => {
isEscaping.current = true;
// No need to update if it hasn't changed
setText(initialText);
// force the text to be the initial value
@@ -141,16 +148,30 @@ export default function useReactiveTextInput(
event.stopPropagation();
}
// for cells that opt out of tab-submit, track tab-out without preventing navigation
if (event.key === 'Tab' && options?.submitOnTab === false) {
isTabbing.current = true;
}
hotKeyHandler(event);
};
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, options?.submitOnTab, text]);
return {
value: text,
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
onBlur: (event: ChangeEvent) => {
onChange: (event) => handleChange(event.target.value),
onBlur: (event) => {
if (isTabbing.current) {
isTabbing.current = false;
(options?.onTabCancel ?? options?.onCancelUpdate)?.();
return;
}
if (isEscaping.current) {
isEscaping.current = false;
return;
}
if (!isKeyboardSubmitting.current) {
handleSubmit((event.target as HTMLInputElement).value);
handleSubmit(event.target.value);
}
},
onKeyDown: keyHandler,
@@ -49,6 +49,10 @@ function DurationInput({
setIsEditing(false);
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
};
const handleTabOut = useCallback(() => {
setIsEditing(false);
// Tab moves focus naturally; do not steal it back to parent
}, []);
const handleUpdate = useCallback(
(newValue: string) => {
@@ -95,8 +99,10 @@ function DurationInput({
ref={inputRef}
initialValue={timeString}
allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value
submitOnTab={false}
handleUpdate={handleUpdate}
handleCancelUpdate={handleFakeBlur}
handleTabCancel={handleTabOut}
/>
) : (
<TextLikeInput
@@ -8,13 +8,24 @@ interface SingleLineCellProps {
fieldId?: string;
fieldLabel?: string;
allowSubmitSameValue?: boolean;
submitOnTab?: boolean;
handleUpdate: (newValue: string) => void;
handleCancelUpdate?: () => void;
handleTabCancel?: () => void;
}
const SingleLineCell = forwardRef(
(
{ initialValue, fieldId, fieldLabel, allowSubmitSameValue, handleUpdate, handleCancelUpdate }: SingleLineCellProps,
{
initialValue,
fieldId,
fieldLabel,
allowSubmitSameValue,
submitOnTab,
handleUpdate,
handleCancelUpdate,
handleTabCancel,
}: SingleLineCellProps,
inputRef,
) => {
const ref = useRef<HTMLInputElement | null>(null);
@@ -25,7 +36,9 @@ const SingleLineCell = forwardRef(
allowKeyboardNavigation: true,
submitOnEnter: true, // single line should submit on enter
submitOnCtrlEnter: true,
submitOnTab,
onCancelUpdate: handleCancelUpdate,
onTabCancel: handleTabCancel,
});
// expose a subset of the methods to the parent
@@ -50,6 +50,10 @@ function TimeInputDuration({
setIsEditing(false);
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
};
const handleTabOut = useCallback(() => {
setIsEditing(false);
// Tab moves focus naturally; do not steal it back to parent
}, []);
const handleUpdate = useCallback(
(newValue: string) => {
@@ -96,8 +100,10 @@ function TimeInputDuration({
ref={inputRef}
initialValue={timeString}
allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value
submitOnTab={false}
handleUpdate={handleUpdate}
handleCancelUpdate={handleFakeBlur}
handleTabCancel={handleTabOut}
/>
) : (
<TextLikeInput