Refactor command menu input (#16773)

- Open input on item click
- `CommandMenuItemNumberInput` and `CommandMenuItemTextInput` don't only
define the input but the whole menu item now
- Fixed behavior on escape

## Video QA


https://github.com/user-attachments/assets/bf2f03e9-d07c-4a1e-9bc8-6606839269ff
This commit is contained in:
Raphaël Bosi
2025-12-23 14:00:04 +01:00
committed by GitHub
parent 81918e8720
commit 7b6071619c
3 changed files with 173 additions and 80 deletions
@@ -1,44 +1,55 @@
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 { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import styled from '@emotion/styled';
import { useState } from 'react';
import { Key } from 'ts-key-enum';
import { useRef, useState } from 'react';
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 handleChange = (text: string) => {
setDraftValue(text);
if (hasError) {
setHasError(false);
}
};
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const handleCommit = () => {
const handleCommit = (draftValue: string) => {
if (!canBeCastAsNumberOrNull(draftValue)) {
setHasError(true);
setDraftValue(value);
return;
}
@@ -58,32 +69,76 @@ export const CommandMenuItemNumberInput = ({
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
pushFocusItemToFocusStack({
focusId,
component: {
type: FocusComponentType.TEXT_INPUT,
instanceId: focusId,
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
};
const handleBlur = () => {
handleCommit();
removeFocusItemFromFocusStackById({ focusId });
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === Key.Enter || event.key === Key.Escape) {
event.stopPropagation();
handleCommit();
} else {
event.stopPropagation();
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: handleClickOutside,
});
const handleChange = (text: string) => {
setDraftValue(text);
if (hasError) {
setHasError(false);
}
};
const focusInput = () => {
inputRef.current?.focus();
};
return (
<StyledRightAlignedTextInput
value={draftValue}
sizeVariant="sm"
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
error={hasError ? ' ' : undefined}
noErrorHelper
<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}
/>
}
/>
);
};
@@ -1,9 +1,17 @@
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 { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import styled from '@emotion/styled';
import { useState } from 'react';
import { Key } from 'ts-key-enum';
import { useRef, useState } from 'react';
import { type IconComponent } from 'twenty-ui/display';
type CommandMenuItemTextInputProps = {
id: string;
label: string;
Icon?: IconComponent;
value: string;
onChange: (value: string) => void;
placeholder?: string;
@@ -16,46 +24,84 @@ const StyledRightAlignedTextInput = styled(TextInput)`
`;
export const CommandMenuItemTextInput = ({
id,
label,
Icon,
value,
onChange,
placeholder,
}: CommandMenuItemTextInputProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const focusId = `${id}-input`;
const [draftValue, setDraftValue] = useState(value);
const handleChange = (text: string) => {
setDraftValue(text);
};
const handleCommit = () => {
onChange(draftValue);
};
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
pushFocusItemToFocusStack({
focusId,
component: {
type: FocusComponentType.TEXT_INPUT,
instanceId: focusId,
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
};
const handleBlur = () => {
handleCommit();
removeFocusItemFromFocusStackById({ focusId });
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === Key.Enter || event.key === Key.Escape) {
event.stopPropagation();
handleCommit();
} else {
event.stopPropagation();
}
const handleEscape = () => {
setDraftValue(value);
inputRef.current?.blur();
};
const handleEnter = () => {
onChange(draftValue);
inputRef.current?.blur();
};
const handleClickOutside = () => {
onChange(draftValue);
};
useRegisterInputEvents<string>({
focusId,
inputRef: inputRef,
inputValue: draftValue,
onEscape: handleEscape,
onEnter: handleEnter,
onClickOutside: handleClickOutside,
});
const focusInput = () => {
inputRef.current?.focus();
};
return (
<StyledRightAlignedTextInput
value={draftValue}
sizeVariant="sm"
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
<CommandMenuItem
id={id}
label={label}
Icon={Icon}
onClick={focusInput}
RightComponent={
<StyledRightAlignedTextInput
ref={inputRef}
value={draftValue}
sizeVariant="sm"
onChange={setDraftValue}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
textClickOutsideId={focusId}
/>
}
/>
);
};
@@ -110,28 +110,24 @@ export const ChartSettingItem = ({
return (
<SelectableListItem key={item.id} itemId={item.id}>
<CommandMenuItem
<CommandMenuItemNumberInput
id={item.id}
label={t(item.label)}
Icon={item.Icon}
RightComponent={
<CommandMenuItemNumberInput
value={stringValue}
onChange={handleInputChange}
onValidate={(value) =>
!isDefined(value) ||
isMinMaxRangeValid(
item.id as
| CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE
| CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE,
value,
configuration,
)
}
placeholder={
item.inputPlaceholder ? t(item.inputPlaceholder) : undefined
}
/>
value={stringValue}
onChange={handleInputChange}
onValidate={(value) =>
!isDefined(value) ||
isMinMaxRangeValid(
item.id as
| CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE
| CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE,
value,
configuration,
)
}
placeholder={
item.inputPlaceholder ? t(item.inputPlaceholder) : undefined
}
/>
</SelectableListItem>
@@ -144,18 +140,14 @@ export const ChartSettingItem = ({
return (
<SelectableListItem key={item.id} itemId={item.id}>
<CommandMenuItem
<CommandMenuItemTextInput
id={item.id}
label={t(item.label)}
Icon={item.Icon}
RightComponent={
<CommandMenuItemTextInput
value={stringValue}
onChange={handleTextInputChange}
placeholder={
item.inputPlaceholder ? t(item.inputPlaceholder) : undefined
}
/>
value={stringValue}
onChange={handleTextInputChange}
placeholder={
item.inputPlaceholder ? t(item.inputPlaceholder) : undefined
}
/>
</SelectableListItem>