New TitleInput UI component for side panel (#11192)
# Description I previously introduced the `RecordTitleCell` component, but it was coupled with the field context, so it was only usable for record fields. This PR: - Introduces a new component `TitleInput` for side panel pages which needed to have an editable title which wasn't a record field. - Fixes the hotkey scope problem with the workflow step page title - Introduces a new hook `useUpdateCommandMenuPageInfo`, to update the side panel page title and icon. - Fixes workflow side panel UI - Adds jest tests and stories # Video https://github.com/user-attachments/assets/c501245c-4492-4351-b761-05b5abc4bd14
This commit is contained in:
@ -0,0 +1,182 @@
|
||||
import {
|
||||
TextInputV2,
|
||||
TextInputV2Size,
|
||||
} from '@/ui/input/components/TextInputV2';
|
||||
import { useRef, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import styled from '@emotion/styled';
|
||||
import { OverflowingTextWithTooltip } from 'twenty-ui';
|
||||
|
||||
type InputProps = {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
hotkeyScope?: string;
|
||||
onEnter?: () => void;
|
||||
onEscape?: () => void;
|
||||
onClickOutside?: () => void;
|
||||
onTab?: () => void;
|
||||
onShiftTab?: () => void;
|
||||
sizeVariant?: TextInputV2Size;
|
||||
};
|
||||
|
||||
export type TitleInputProps = {
|
||||
disabled?: boolean;
|
||||
} & InputProps;
|
||||
|
||||
const StyledDiv = styled.div<{
|
||||
sizeVariant: TextInputV2Size;
|
||||
disabled?: boolean;
|
||||
}>`
|
||||
background: inherit;
|
||||
border: none;
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')};
|
||||
overflow: hidden;
|
||||
height: ${({ sizeVariant }) =>
|
||||
sizeVariant === 'xs'
|
||||
? '20px'
|
||||
: sizeVariant === 'sm'
|
||||
? '24px'
|
||||
: sizeVariant === 'md'
|
||||
? '28px'
|
||||
: '32px'};
|
||||
padding: ${({ theme }) => theme.spacing(0, 1.25)};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
:hover {
|
||||
background: ${({ theme, disabled }) =>
|
||||
disabled ? 'inherit' : theme.background.transparent.light};
|
||||
}
|
||||
`;
|
||||
|
||||
const Input = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
hotkeyScope = 'title-input',
|
||||
onEnter,
|
||||
onEscape,
|
||||
onClickOutside,
|
||||
onTab,
|
||||
onShiftTab,
|
||||
setIsOpened,
|
||||
sizeVariant,
|
||||
}: InputProps & { setIsOpened: (isOpened: boolean) => void }) => {
|
||||
const wrapperRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [draftValue, setDraftValue] = useState(value ?? '');
|
||||
|
||||
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (isDefined(value)) {
|
||||
event.target.select();
|
||||
}
|
||||
};
|
||||
|
||||
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
|
||||
|
||||
const handleLeaveFocus = () => {
|
||||
setIsOpened(false);
|
||||
goBackToPreviousHotkeyScope();
|
||||
};
|
||||
|
||||
useRegisterInputEvents<string>({
|
||||
inputRef: wrapperRef,
|
||||
inputValue: draftValue,
|
||||
onEnter: () => {
|
||||
handleLeaveFocus();
|
||||
onEnter?.();
|
||||
},
|
||||
onEscape: () => {
|
||||
handleLeaveFocus();
|
||||
onEscape?.();
|
||||
},
|
||||
onClickOutside: (event) => {
|
||||
event.stopImmediatePropagation();
|
||||
handleLeaveFocus();
|
||||
onClickOutside?.();
|
||||
},
|
||||
onTab: () => {
|
||||
handleLeaveFocus();
|
||||
onTab?.();
|
||||
},
|
||||
onShiftTab: () => {
|
||||
handleLeaveFocus();
|
||||
onShiftTab?.();
|
||||
},
|
||||
hotkeyScope: hotkeyScope,
|
||||
});
|
||||
|
||||
return (
|
||||
<TextInputV2
|
||||
ref={wrapperRef}
|
||||
autoGrow
|
||||
sizeVariant={sizeVariant}
|
||||
inheritFontStyles
|
||||
value={draftValue}
|
||||
onChange={(text) => {
|
||||
setDraftValue(text);
|
||||
onChange?.(text);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
onFocus={handleFocus}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TitleInput = ({
|
||||
disabled,
|
||||
value,
|
||||
sizeVariant = 'md',
|
||||
onChange,
|
||||
placeholder,
|
||||
hotkeyScope = 'title-input',
|
||||
onEnter,
|
||||
onEscape,
|
||||
onClickOutside,
|
||||
onTab,
|
||||
onShiftTab,
|
||||
}: TitleInputProps) => {
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
|
||||
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpened ? (
|
||||
<Input
|
||||
sizeVariant={sizeVariant}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
hotkeyScope={hotkeyScope}
|
||||
onEnter={onEnter}
|
||||
onEscape={onEscape}
|
||||
onClickOutside={onClickOutside}
|
||||
onTab={onTab}
|
||||
onShiftTab={onShiftTab}
|
||||
setIsOpened={setIsOpened}
|
||||
/>
|
||||
) : (
|
||||
<StyledDiv
|
||||
sizeVariant={sizeVariant}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setIsOpened(true);
|
||||
setHotkeyScopeAndMemorizePreviousScope(hotkeyScope);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OverflowingTextWithTooltip text={value || placeholder} />
|
||||
</StyledDiv>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,64 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { ComponentDecorator } from 'twenty-ui';
|
||||
|
||||
import { TitleInput } from '@/ui/input/components/TitleInput';
|
||||
|
||||
const meta: Meta<typeof TitleInput> = {
|
||||
title: 'UI/Input/TitleInput',
|
||||
component: TitleInput,
|
||||
decorators: [ComponentDecorator],
|
||||
args: {
|
||||
placeholder: 'Enter title',
|
||||
hotkeyScope: 'titleInput',
|
||||
sizeVariant: 'md',
|
||||
},
|
||||
argTypes: {
|
||||
hotkeyScope: { control: false },
|
||||
sizeVariant: { control: false },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TitleInput>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: { value: 'Sample Title' },
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true, value: 'Disabled Title' },
|
||||
};
|
||||
|
||||
export const ExtraSmall: Story = {
|
||||
args: { sizeVariant: 'xs', value: 'Extra Small Title' },
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: { sizeVariant: 'sm', value: 'Small Title' },
|
||||
};
|
||||
|
||||
export const Medium: Story = {
|
||||
args: { sizeVariant: 'md', value: 'Medium Title' },
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: { sizeVariant: 'lg', value: 'Large Title' },
|
||||
};
|
||||
|
||||
export const WithLongText: Story = {
|
||||
args: {
|
||||
value:
|
||||
'This is a very long title that will likely overflow and demonstrate the tooltip behavior of the component',
|
||||
},
|
||||
parameters: {
|
||||
container: {
|
||||
width: 250,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomPlaceholder: Story = {
|
||||
args: { placeholder: 'Custom placeholder example' },
|
||||
};
|
||||
Reference in New Issue
Block a user