Files
twenty/packages/twenty-front/src/modules/command-menu/components/CommandMenuItemNumberInput.tsx
T
nitin 4b34add51c Fix command menu input click outside listener blocking other elements (#16934)
click outside listeners for `CommandMenuItemNumberInput` and
`CommandMenuItemTextInput` were always active, even when the input
wasn't focused. This blocked clicks on other elements like toggles in
chart settings.

fixed by only enabling click outside listener when input is actually
focused
2026-01-05 11:10:54 +00:00

152 lines
4.0 KiB
TypeScript

import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { useRegisterInputEvents } from '@/object-record/record-field/ui/meta-types/input/hooks/useRegisterInputEvents';
import { TextInput } from '@/ui/input/components/TextInput';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import styled from '@emotion/styled';
import { useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { type IconComponent } from 'twenty-ui/display';
import {
canBeCastAsNumberOrNull,
castAsNumberOrNull,
} from '~/utils/cast-as-number-or-null';
type CommandMenuItemNumberInputProps = {
id: string;
label: string;
Icon?: IconComponent;
value: string;
onChange: (value: number | null) => void;
onValidate?: (value: number | null) => boolean;
placeholder?: string;
};
const StyledRightAlignedTextInput = styled(TextInput)`
input {
text-align: right;
}
`;
export const CommandMenuItemNumberInput = ({
id,
label,
Icon,
value,
onChange,
onValidate,
placeholder,
}: CommandMenuItemNumberInputProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const focusId = `${id}-input`;
const [draftValue, setDraftValue] = useState(value);
const [hasError, setHasError] = useState(false);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
const isNumberInputCurrentlyFocused = currentFocusId === focusId;
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const handleCommit = (draftValue: string) => {
if (!canBeCastAsNumberOrNull(draftValue)) {
setHasError(true);
return;
}
const numericValue = castAsNumberOrNull(draftValue);
if (isDefined(onValidate)) {
const isValid = onValidate(numericValue);
if (!isValid) {
setHasError(true);
return;
}
}
onChange(numericValue);
setHasError(false);
};
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
pushFocusItemToFocusStack({
focusId,
component: {
type: FocusComponentType.TEXT_INPUT,
instanceId: focusId,
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
};
const handleBlur = () => {
removeFocusItemFromFocusStackById({ focusId });
};
const handleEscape = () => {
setDraftValue(value);
inputRef.current?.blur();
};
const handleClickOutside = () => {
handleCommit(draftValue);
};
const handleEnter = () => {
handleCommit(draftValue);
inputRef.current?.blur();
};
useRegisterInputEvents<string>({
focusId,
inputRef: inputRef,
inputValue: draftValue,
onEscape: handleEscape,
onEnter: handleEnter,
onClickOutside: isNumberInputCurrentlyFocused
? handleClickOutside
: undefined,
});
const handleChange = (text: string) => {
setDraftValue(text);
if (hasError) {
setHasError(false);
}
};
const focusInput = () => {
inputRef.current?.focus();
};
return (
<CommandMenuItem
id={id}
label={label}
Icon={Icon}
onClick={focusInput}
RightComponent={
<StyledRightAlignedTextInput
ref={inputRef}
value={draftValue}
sizeVariant="sm"
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
error={hasError ? ' ' : undefined}
noErrorHelper
textClickOutsideId={focusId}
/>
}
/>
);
};