Refactor input arch (#1982)

This commit is contained in:
Charles Bochet
2023-10-12 17:41:50 +02:00
committed by GitHub
parent 09fd5b6454
commit 6b990c8501
47 changed files with 312 additions and 523 deletions

View File

@ -8,7 +8,7 @@ import { RoundedIconButton } from '@/ui/button/components/RoundedIconButton';
import { IconArrowRight } from '@/ui/icon/index';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { InputHotkeyScope } from '../text/types/InputHotkeyScope';
import { InputHotkeyScope } from '../types/InputHotkeyScope';
const MAX_ROWS = 5;

View File

@ -1,59 +0,0 @@
import { useEffect, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCheck, IconX } from '@/ui/icon';
const StyledEditableBooleanFieldContainer = styled.div`
align-items: center;
cursor: pointer;
display: flex;
height: 100%;
width: 100%;
`;
const StyledEditableBooleanFieldValue = styled.div`
margin-left: ${({ theme }) => theme.spacing(1)};
`;
type BooleanInputProps = {
value: boolean;
onToggle?: (newValue: boolean) => void;
testId?: string;
};
export const BooleanInput = ({
value,
onToggle,
testId,
}: BooleanInputProps) => {
const [internalValue, setInternalValue] = useState(value);
useEffect(() => {
setInternalValue(value);
}, [value]);
const handleClick = () => {
setInternalValue(!internalValue);
onToggle?.(!internalValue);
};
const theme = useTheme();
return (
<StyledEditableBooleanFieldContainer
onClick={handleClick}
data-testid={testId}
>
{internalValue ? (
<IconCheck size={theme.icon.size.sm} />
) : (
<IconX size={theme.icon.size.sm} />
)}
<StyledEditableBooleanFieldValue>
{internalValue ? 'True' : 'False'}
</StyledEditableBooleanFieldValue>
</StyledEditableBooleanFieldContainer>
);
};

View File

@ -1,103 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { flip, offset, useFloating } from '@floating-ui/react';
import { DateDisplay } from '@/ui/field/meta-types/display/content-display/components/DateDisplay';
import { Nullable } from '~/types/Nullable';
import { useRegisterInputEvents } from '../hooks/useRegisterInputEvents';
import { DatePicker } from './DatePicker';
const StyledCalendarContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
margin-top: 1px;
position: absolute;
z-index: 1;
`;
const StyledInputContainer = styled.div`
padding: ${({ theme }) => theme.spacing(0)} ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export type DateInputProps = {
value: Nullable<Date>;
onEnter: (newDate: Nullable<Date>) => void;
onEscape: (newDate: Nullable<Date>) => void;
onClickOutside: (
event: MouseEvent | TouchEvent,
newDate: Nullable<Date>,
) => void;
hotkeyScope: string;
};
export const DateInput = ({
value,
hotkeyScope,
onEnter,
onEscape,
onClickOutside,
}: DateInputProps) => {
const theme = useTheme();
const [internalValue, setInternalValue] = useState(value);
const wrapperRef = useRef(null);
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
middleware: [
flip(),
offset({
mainAxis: theme.spacingMultiplicator * 2,
}),
],
});
const handleChange = (newDate: Date) => {
setInternalValue(newDate);
};
useEffect(() => {
setInternalValue(value);
}, [value]);
useRegisterInputEvents({
inputRef: wrapperRef,
inputValue: internalValue,
onEnter,
onEscape,
onClickOutside,
hotkeyScope,
});
return (
<div ref={wrapperRef}>
<div ref={refs.setReference}>
<StyledInputContainer>
<DateDisplay value={internalValue ?? new Date()} />
</StyledInputContainer>
</div>
<div ref={refs.setFloating} style={floatingStyles}>
<StyledCalendarContainer>
<DatePicker
date={internalValue ?? new Date()}
onChange={handleChange}
onMouseSelect={(newDate: Date) => {
onEnter(newDate);
}}
/>
</StyledCalendarContainer>
</div>
</div>
);
};

View File

@ -1,171 +0,0 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { FieldDoubleText } from '@/ui/field/types/FieldDoubleText';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
import { StyledInput } from './TextInput';
const StyledContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
input {
width: ${({ theme }) => theme.spacing(24)};
}
& > input:last-child {
border-left: 1px solid ${({ theme }) => theme.border.color.medium};
padding-left: ${({ theme }) => theme.spacing(2)};
}
`;
type DoubleTextInputProps = {
firstValue: string;
secondValue: string;
firstValuePlaceholder: string;
secondValuePlaceholder: string;
hotkeyScope: string;
onEnter: (newDoubleTextValue: FieldDoubleText) => void;
onEscape: (newDoubleTextValue: FieldDoubleText) => void;
onTab?: (newDoubleTextValue: FieldDoubleText) => void;
onShiftTab?: (newDoubleTextValue: FieldDoubleText) => void;
onClickOutside: (
event: MouseEvent | TouchEvent,
newDoubleTextValue: FieldDoubleText,
) => void;
};
export const DoubleTextInput = ({
firstValue,
secondValue,
firstValuePlaceholder,
secondValuePlaceholder,
hotkeyScope,
onClickOutside,
onEnter,
onEscape,
onShiftTab,
onTab,
}: DoubleTextInputProps) => {
const [firstInternalValue, setFirstInternalValue] = useState(firstValue);
const [secondInternalValue, setSecondInternalValue] = useState(secondValue);
const firstValueInputRef = useRef<HTMLInputElement>(null);
const secondValueInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setFirstInternalValue(firstValue);
setSecondInternalValue(secondValue);
}, [firstValue, secondValue]);
const handleChange = (
newFirstValue: string,
newSecondValue: string,
): void => {
setFirstInternalValue(newFirstValue);
setSecondInternalValue(newSecondValue);
};
const [focusPosition, setFocusPosition] = useState<'left' | 'right'>('left');
useScopedHotkeys(
Key.Enter,
() => {
onEnter({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
},
hotkeyScope,
[onEnter, firstInternalValue, secondInternalValue],
);
useScopedHotkeys(
Key.Escape,
() => {
onEscape({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
},
hotkeyScope,
[onEscape, firstInternalValue, secondInternalValue],
);
useScopedHotkeys(
'tab',
() => {
if (focusPosition === 'left') {
setFocusPosition('right');
secondValueInputRef.current?.focus();
} else {
onTab?.({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
}
},
hotkeyScope,
[onTab, firstInternalValue, secondInternalValue, focusPosition],
);
useScopedHotkeys(
'shift+tab',
() => {
if (focusPosition === 'right') {
setFocusPosition('left');
firstValueInputRef.current?.focus();
} else {
onShiftTab?.({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
}
},
hotkeyScope,
[onShiftTab, firstInternalValue, secondInternalValue, focusPosition],
);
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
onClickOutside?.(event, {
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
},
enabled: isDefined(onClickOutside),
});
return (
<StyledContainer ref={containerRef}>
<StyledInput
autoComplete="off"
autoFocus
onFocus={() => setFocusPosition('left')}
ref={firstValueInputRef}
placeholder={firstValuePlaceholder}
value={firstInternalValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
handleChange(event.target.value, secondInternalValue);
}}
/>
<StyledInput
autoComplete="off"
onFocus={() => setFocusPosition('right')}
ref={secondValueInputRef}
placeholder={secondValuePlaceholder}
value={secondInternalValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
handleChange(firstInternalValue, event.target.value);
}}
/>
</StyledContainer>
);
};

View File

@ -1,7 +1,7 @@
import { ChangeEvent } from 'react';
import styled from '@emotion/styled';
import { StyledInput } from '@/ui/input/components/TextInput';
import { StyledInput } from '@/ui/field/meta-types/input/components/internal/TextInput';
import { ComputeNodeDimensions } from '@/ui/utilities/dimensions/components/ComputeNodeDimensions';
export type EntityTitleDoubleTextInputProps = {

View File

@ -1,97 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import ReactPhoneNumberInput from 'react-phone-number-input';
import styled from '@emotion/styled';
import { useRegisterInputEvents } from '../hooks/useRegisterInputEvents';
import { CountryPickerDropdownButton } from './CountryPickerDropdownButton';
import 'react-phone-number-input/style.css';
const StyledContainer = styled.div`
align-items: center;
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
justify-content: center;
`;
const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)`
font-family: ${({ theme }) => theme.font.family};
height: 32px;
.PhoneInputInput {
background: ${({ theme }) => theme.background.transparent.secondary};
border: none;
color: ${({ theme }) => theme.font.color.primary};
&::placeholder,
&::-webkit-input-placeholder {
color: ${({ theme }) => theme.font.color.light};
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.medium};
}
:focus {
outline: none;
}
}
`;
export type PhoneInputProps = {
placeholder?: string;
autoFocus?: boolean;
value: string;
onEnter: (newText: string) => void;
onEscape: (newText: string) => void;
onTab?: (newText: string) => void;
onShiftTab?: (newText: string) => void;
onClickOutside: (event: MouseEvent | TouchEvent, inputValue: string) => void;
hotkeyScope: string;
};
export const PhoneInput = ({
autoFocus,
value,
onEnter,
onEscape,
onTab,
onShiftTab,
onClickOutside,
hotkeyScope,
}: PhoneInputProps) => {
const [internalValue, setInternalValue] = useState<string | undefined>(value);
const wrapperRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setInternalValue(value);
}, [value]);
useRegisterInputEvents({
inputRef: wrapperRef,
inputValue: internalValue ?? '',
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
hotkeyScope,
});
return (
<StyledContainer ref={wrapperRef}>
<StyledCustomPhoneInput
autoFocus={autoFocus}
placeholder="Phone number"
value={value}
onChange={setInternalValue}
international={true}
withCountryCallingCode={true}
countrySelectComponent={CountryPickerDropdownButton}
/>
</StyledContainer>
);
};

View File

@ -1,108 +0,0 @@
import { useState } from 'react';
import styled from '@emotion/styled';
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
justify-content: flex-start;
width: 100%;
`;
const StyledProgressBarItemContainer = styled.div`
align-items: center;
display: flex;
height: ${({ theme }) => theme.spacing(4)};
padding-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledProgressBarItem = styled.div<{
isFirst: boolean;
isLast: boolean;
isActive: boolean;
}>`
background-color: ${({ theme, isActive }) =>
isActive
? theme.font.color.secondary
: theme.background.transparent.medium};
border-bottom-left-radius: ${({ theme, isFirst }) =>
isFirst ? theme.border.radius.sm : theme.border.radius.xs};
border-bottom-right-radius: ${({ theme, isLast }) =>
isLast ? theme.border.radius.sm : theme.border.radius.xs};
border-top-left-radius: ${({ theme, isFirst }) =>
isFirst ? theme.border.radius.sm : theme.border.radius.xs};
border-top-right-radius: ${({ theme, isLast }) =>
isLast ? theme.border.radius.sm : theme.border.radius.xs};
height: ${({ theme }) => theme.spacing(2)};
width: ${({ theme }) => theme.spacing(3)};
`;
const StyledProgressBarContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
justify-content: flex-start;
width: 100%;
`;
const StyledLabel = styled.div`
width: ${({ theme }) => theme.spacing(12)};
`;
const PROBABILITY_VALUES = [
{ label: '0%', value: 0 },
{ label: '25%', value: 25 },
{ label: '50%', value: 50 },
{ label: '75%', value: 75 },
{ label: '100%', value: 100 },
];
type ProbabilityInputProps = {
probabilityIndex: number | null;
onChange: (newValue: number) => void;
};
export const ProbabilityInput = ({
onChange,
probabilityIndex,
}: ProbabilityInputProps) => {
const [hoveredProbabilityIndex, setHoveredProbabilityIndex] = useState<
number | null
>(null);
const probabilityIndexToShow =
hoveredProbabilityIndex ?? probabilityIndex ?? 0;
return (
<StyledContainer>
<StyledLabel>
{PROBABILITY_VALUES[probabilityIndexToShow].label}
</StyledLabel>
<StyledProgressBarContainer>
{PROBABILITY_VALUES.map((probability, probabilityIndexToSelect) => (
<StyledProgressBarItemContainer
key={probabilityIndexToSelect}
onClick={() => onChange(probability.value)}
onMouseEnter={() =>
setHoveredProbabilityIndex(probabilityIndexToSelect)
}
onMouseLeave={() => setHoveredProbabilityIndex(null)}
>
<StyledProgressBarItem
isActive={
hoveredProbabilityIndex || hoveredProbabilityIndex === 0
? probabilityIndexToSelect <= hoveredProbabilityIndex
: probabilityIndexToSelect <= probabilityIndexToShow
}
key={probability.label}
isFirst={probabilityIndexToSelect === 0}
isLast={
probabilityIndexToSelect === PROBABILITY_VALUES.length - 1
}
/>
</StyledProgressBarItemContainer>
))}
</StyledProgressBarContainer>
</StyledContainer>
);
};

View File

@ -1,70 +1,203 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import {
ChangeEvent,
FocusEventHandler,
ForwardedRef,
forwardRef,
InputHTMLAttributes,
useRef,
useState,
} from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { textInputStyle } from '@/ui/theme/constants/effects';
import { IconAlertCircle } from '@/ui/icon';
import { IconEye, IconEyeOff } from '@/ui/icon/index';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
import { useRegisterInputEvents } from '../hooks/useRegisterInputEvents';
import { InputHotkeyScope } from '../types/InputHotkeyScope';
export const StyledInput = styled.input`
margin: 0;
width: 100%;
${textInputStyle}
type TextInputComponentProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'onChange'
> & {
label?: string;
onChange?: (text: string) => void;
fullWidth?: boolean;
disableHotkeys?: boolean;
error?: string;
};
const StyledContainer = styled.div<Pick<TextInputComponentProps, 'fullWidth'>>`
display: flex;
flex-direction: column;
width: ${({ fullWidth }) => (fullWidth ? `100%` : 'auto')};
`;
type TextInputProps = {
placeholder?: string;
autoFocus?: boolean;
value: string;
onEnter: (newText: string) => void;
onEscape: (newText: string) => void;
onTab?: (newText: string) => void;
onShiftTab?: (newText: string) => void;
onClickOutside: (event: MouseEvent | TouchEvent, inputValue: string) => void;
hotkeyScope: string;
};
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-transform: uppercase;
`;
export const TextInput = ({
placeholder,
autoFocus,
value,
hotkeyScope,
onEnter,
onEscape,
onTab,
onShiftTab,
onClickOutside,
}: TextInputProps) => {
const [internalText, setInternalText] = useState(value);
const StyledInputContainer = styled.div`
display: flex;
flex-direction: row;
const wrapperRef = useRef(null);
width: 100%;
`;
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setInternalText(event.target.value);
const StyledInput = styled.input<Pick<TextInputComponentProps, 'fullWidth'>>`
background-color: ${({ theme }) => theme.background.tertiary};
border: none;
border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm};
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-grow: 1;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
outline: none;
padding: ${({ theme }) => theme.spacing(2)};
width: 100%;
&::placeholder,
&::-webkit-input-placeholder {
color: ${({ theme }) => theme.font.color.light};
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.medium};
}
`;
const StyledErrorHelper = styled.div`
color: ${({ theme }) => theme.color.red};
font-size: ${({ theme }) => theme.font.size.xs};
padding: ${({ theme }) => theme.spacing(1)};
`;
const StyledTrailingIconContainer = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.tertiary};
border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm};
border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
justify-content: center;
padding-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledTrailingIcon = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
cursor: pointer;
display: flex;
justify-content: center;
`;
const INPUT_TYPE_PASSWORD = 'password';
const TextInputComponent = (
{
label,
value,
onChange,
onFocus,
onBlur,
fullWidth,
error,
required,
type,
disableHotkeys = false,
autoFocus,
placeholder,
disabled,
tabIndex,
}: TextInputComponentProps,
// eslint-disable-next-line twenty/component-props-naming
ref: ForwardedRef<HTMLInputElement>,
): JSX.Element => {
const theme = useTheme();
const inputRef = useRef<HTMLInputElement>(null);
const combinedRef = useCombinedRefs(ref, inputRef);
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const handleFocus: FocusEventHandler<HTMLInputElement> = (e) => {
onFocus?.(e);
if (!disableHotkeys) {
setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput);
}
};
useEffect(() => {
setInternalText(value);
}, [value]);
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
onBlur?.(e);
if (!disableHotkeys) {
goBackToPreviousHotkeyScope();
}
};
useRegisterInputEvents({
inputRef: wrapperRef,
inputValue: internalText,
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
hotkeyScope,
});
useScopedHotkeys(
[Key.Escape, Key.Enter],
() => {
inputRef.current?.blur();
},
InputHotkeyScope.TextInput,
);
const [passwordVisible, setPasswordVisible] = useState(false);
const handleTogglePasswordVisibility = () => {
setPasswordVisible(!passwordVisible);
};
return (
<StyledInput
autoComplete="off"
ref={wrapperRef}
placeholder={placeholder}
onChange={handleChange}
autoFocus={autoFocus}
value={internalText}
/>
<StyledContainer fullWidth={fullWidth ?? false}>
{label && <StyledLabel>{label + (required ? '*' : '')}</StyledLabel>}
<StyledInputContainer>
<StyledInput
autoComplete="off"
ref={combinedRef}
tabIndex={tabIndex ?? 0}
onFocus={handleFocus}
onBlur={handleBlur}
type={passwordVisible ? 'text' : type}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange?.(event.target.value);
}}
{...{ autoFocus, disabled, placeholder, required, value }}
/>
<StyledTrailingIconContainer>
{error && (
<StyledTrailingIcon>
<IconAlertCircle size={16} color={theme.color.red} />
</StyledTrailingIcon>
)}
{!error && type === INPUT_TYPE_PASSWORD && (
<StyledTrailingIcon
onClick={handleTogglePasswordVisibility}
data-testid="reveal-password-button"
>
{passwordVisible ? (
<IconEyeOff size={theme.icon.size.md} />
) : (
<IconEye size={theme.icon.size.md} />
)}
</StyledTrailingIcon>
)}
</StyledTrailingIconContainer>
</StyledInputContainer>
{error && <StyledErrorHelper>{error}</StyledErrorHelper>}
</StyledContainer>
);
};
export const TextInput = forwardRef(TextInputComponent);

View File

@ -1,20 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { EmailDisplay } from '../../../field/meta-types/display/content-display/components/EmailDisplay';
const meta: Meta = {
title: 'UI/Input/EmailInputDisplay',
component: EmailDisplay,
decorators: [ComponentWithRouterDecorator],
args: {
value: 'mustajab.ikram@google.com',
},
};
export default meta;
type Story = StoryObj<typeof EmailDisplay>;
export const Default: Story = {};

View File

@ -220,17 +220,17 @@ const StyledContainer = styled.div`
}
`;
export type DatePickerProps = {
export type InternalDatePickerProps = {
date: Date;
onMouseSelect?: (date: Date) => void;
onChange?: (date: Date) => void;
};
export const DatePicker = ({
export const InternalDatePicker = ({
date,
onChange,
onMouseSelect,
}: DatePickerProps) => (
}: InternalDatePickerProps) => (
<StyledContainer>
<ReactDatePicker
open={true}

View File

@ -4,11 +4,11 @@ import { userEvent, within } from '@storybook/testing-library';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { DatePicker } from '../DatePicker';
import { InternalDatePicker } from '../InternalDatePicker';
const meta: Meta<typeof DatePicker> = {
title: 'UI/Input/DatePicker',
component: DatePicker,
const meta: Meta<typeof InternalDatePicker> = {
title: 'UI/Input/InternalDatePicker',
component: InternalDatePicker,
decorators: [ComponentDecorator],
argTypes: {
date: { control: 'date' },
@ -17,7 +17,7 @@ const meta: Meta<typeof DatePicker> = {
};
export default meta;
type Story = StoryObj<typeof DatePicker>;
type Story = StoryObj<typeof InternalDatePicker>;
export const Default: Story = {};

View File

@ -10,9 +10,9 @@ import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu';
import { useDropdown } from '@/ui/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/dropdown/scopes/DropdownScope';
import { IconChevronDown } from '@/ui/icon';
import { IconWorld } from '@/ui/input/constants/icons';
import { IconWorld } from '../constants/icons';
import { CountryPickerHotkeyScope } from '../Types/CountryPickerHotkeyScope';
import { CountryPickerHotkeyScope } from '../types/CountryPickerHotkeyScope';
import { CountryPickerDropdownSelect } from './CountryPickerDropdownSelect';

View File

@ -1,69 +0,0 @@
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
export const useRegisterInputEvents = <T>({
inputRef,
inputValue,
onEscape,
onEnter,
onTab,
onShiftTab,
onClickOutside,
hotkeyScope,
}: {
inputRef: React.RefObject<any>;
inputValue: T;
onEscape: (inputValue: T) => void;
onEnter: (inputValue: T) => void;
onTab?: (inputValue: T) => void;
onShiftTab?: (inputValue: T) => void;
onClickOutside?: (event: MouseEvent | TouchEvent, inputValue: T) => void;
hotkeyScope: string;
}) => {
useListenClickOutside({
refs: [inputRef],
callback: (event) => {
event.stopImmediatePropagation();
onClickOutside?.(event, inputValue);
},
enabled: isDefined(onClickOutside),
});
useScopedHotkeys(
'enter',
() => {
onEnter?.(inputValue);
},
hotkeyScope,
[onEnter, inputValue],
);
useScopedHotkeys(
'esc',
() => {
onEscape?.(inputValue);
},
hotkeyScope,
[onEscape, inputValue],
);
useScopedHotkeys(
'tab',
() => {
onTab?.(inputValue);
},
hotkeyScope,
[onTab, inputValue],
);
useScopedHotkeys(
'shift+tab',
() => {
onShiftTab?.(inputValue);
},
hotkeyScope,
[onShiftTab, inputValue],
);
};

View File

@ -1,48 +0,0 @@
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/theme/constants/effects';
import { overlayBackground } from '@/ui/theme/constants/effects';
const StyledInplaceInputTextInput = styled.input`
margin: 0;
width: 100%;
${textInputStyle}
`;
const StyledTextInputContainer = styled.div`
align-items: center;
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
margin-left: -1px;
min-height: 32px;
width: inherit;
${overlayBackground}
z-index: 10;
`;
export type TextInputEditProps = {
placeholder?: string;
value?: string;
onChange?: (newValue: string) => void;
autoFocus?: boolean;
};
export const TextInputEdit = ({
placeholder,
value,
onChange,
autoFocus,
}: TextInputEditProps) => (
<StyledTextInputContainer>
<StyledInplaceInputTextInput
autoComplete="off"
autoFocus={autoFocus}
placeholder={placeholder}
value={value}
onChange={(e) => onChange?.(e.target.value)}
/>
</StyledTextInputContainer>
);

View File

@ -1,203 +0,0 @@
import {
ChangeEvent,
FocusEventHandler,
ForwardedRef,
forwardRef,
InputHTMLAttributes,
useRef,
useState,
} from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { IconAlertCircle } from '@/ui/icon';
import { IconEye, IconEyeOff } from '@/ui/icon/index';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
import { InputHotkeyScope } from '../types/InputHotkeyScope';
type TextInputComponentProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'onChange'
> & {
label?: string;
onChange?: (text: string) => void;
fullWidth?: boolean;
disableHotkeys?: boolean;
error?: string;
};
const StyledContainer = styled.div<Pick<TextInputComponentProps, 'fullWidth'>>`
display: flex;
flex-direction: column;
width: ${({ fullWidth }) => (fullWidth ? `100%` : 'auto')};
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-transform: uppercase;
`;
const StyledInputContainer = styled.div`
display: flex;
flex-direction: row;
width: 100%;
`;
const StyledInput = styled.input<Pick<TextInputComponentProps, 'fullWidth'>>`
background-color: ${({ theme }) => theme.background.tertiary};
border: none;
border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm};
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-grow: 1;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
outline: none;
padding: ${({ theme }) => theme.spacing(2)};
width: 100%;
&::placeholder,
&::-webkit-input-placeholder {
color: ${({ theme }) => theme.font.color.light};
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.medium};
}
`;
const StyledErrorHelper = styled.div`
color: ${({ theme }) => theme.color.red};
font-size: ${({ theme }) => theme.font.size.xs};
padding: ${({ theme }) => theme.spacing(1)};
`;
const StyledTrailingIconContainer = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.tertiary};
border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm};
border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
justify-content: center;
padding-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledTrailingIcon = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
cursor: pointer;
display: flex;
justify-content: center;
`;
const INPUT_TYPE_PASSWORD = 'password';
const TextInputComponent = (
{
label,
value,
onChange,
onFocus,
onBlur,
fullWidth,
error,
required,
type,
disableHotkeys = false,
autoFocus,
placeholder,
disabled,
tabIndex,
}: TextInputComponentProps,
// eslint-disable-next-line twenty/component-props-naming
ref: ForwardedRef<HTMLInputElement>,
): JSX.Element => {
const theme = useTheme();
const inputRef = useRef<HTMLInputElement>(null);
const combinedRef = useCombinedRefs(ref, inputRef);
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const handleFocus: FocusEventHandler<HTMLInputElement> = (e) => {
onFocus?.(e);
if (!disableHotkeys) {
setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput);
}
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
onBlur?.(e);
if (!disableHotkeys) {
goBackToPreviousHotkeyScope();
}
};
useScopedHotkeys(
[Key.Escape, Key.Enter],
() => {
inputRef.current?.blur();
},
InputHotkeyScope.TextInput,
);
const [passwordVisible, setPasswordVisible] = useState(false);
const handleTogglePasswordVisibility = () => {
setPasswordVisible(!passwordVisible);
};
return (
<StyledContainer fullWidth={fullWidth ?? false}>
{label && <StyledLabel>{label + (required ? '*' : '')}</StyledLabel>}
<StyledInputContainer>
<StyledInput
autoComplete="off"
ref={combinedRef}
tabIndex={tabIndex ?? 0}
onFocus={handleFocus}
onBlur={handleBlur}
type={passwordVisible ? 'text' : type}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange?.(event.target.value);
}}
{...{ autoFocus, disabled, placeholder, required, value }}
/>
<StyledTrailingIconContainer>
{error && (
<StyledTrailingIcon>
<IconAlertCircle size={16} color={theme.color.red} />
</StyledTrailingIcon>
)}
{!error && type === INPUT_TYPE_PASSWORD && (
<StyledTrailingIcon
onClick={handleTogglePasswordVisibility}
data-testid="reveal-password-button"
>
{passwordVisible ? (
<IconEyeOff size={theme.icon.size.md} />
) : (
<IconEye size={theme.icon.size.md} />
)}
</StyledTrailingIcon>
)}
</StyledTrailingIconContainer>
</StyledInputContainer>
{error && <StyledErrorHelper>{error}</StyledErrorHelper>}
</StyledContainer>
);
};
export const TextInputSettings = forwardRef(TextInputComponent);

View File

@ -1,142 +0,0 @@
import { useState } from 'react';
import { expect } from '@storybook/jest';
import { jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { TextInputSettings } from '../TextInputSettings';
const changeJestFn = jest.fn();
const meta: Meta<typeof TextInputSettings> = {
title: 'UI/Input/TextInput',
component: TextInputSettings,
decorators: [ComponentDecorator],
args: { value: '', onChange: changeJestFn, placeholder: 'Placeholder' },
};
export default meta;
type Story = StoryObj<typeof TextInputSettings>;
const FakeTextInput = ({
autoFocus,
disableHotkeys = false,
disabled,
error,
fullWidth,
label,
onBlur,
onChange,
onFocus,
placeholder,
required,
tabIndex,
type,
value: initialValue,
}: React.ComponentProps<typeof TextInputSettings>) => {
const [value, setValue] = useState(initialValue);
return (
<TextInputSettings
{...{
autoFocus,
disableHotkeys,
disabled,
error,
fullWidth,
label,
onBlur,
onFocus,
placeholder,
required,
tabIndex,
type,
}}
value={value}
onChange={(text) => {
setValue(text);
onChange?.(text);
}}
/>
);
};
export const Default: Story = {
argTypes: { value: { control: false } },
args: { value: 'A good value ' },
render: ({
autoFocus,
disableHotkeys,
disabled,
error,
fullWidth,
label,
onBlur,
onChange,
onFocus,
placeholder,
required,
tabIndex,
type,
value,
}) => (
<FakeTextInput
{...{
autoFocus,
disableHotkeys,
disabled,
error,
fullWidth,
label,
onBlur,
onChange,
onFocus,
placeholder,
required,
tabIndex,
type,
value,
}}
/>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('textbox');
await userEvent.type(input, 'cou', { delay: 100 });
expect(changeJestFn).toHaveBeenNthCalledWith(1, 'A good value c');
expect(changeJestFn).toHaveBeenNthCalledWith(2, 'A good value co');
expect(changeJestFn).toHaveBeenNthCalledWith(3, 'A good value cou');
},
};
export const Placeholder: Story = {};
export const FullWidth: Story = {
args: { value: 'A good value', fullWidth: true },
};
export const WithLabel: Story = {
args: { label: 'Lorem ipsum' },
};
export const WithError: Story = {
args: { error: 'Lorem ipsum' },
};
export const PasswordInput: Story = {
args: { type: 'password', placeholder: 'Password' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByPlaceholderText('Password');
await userEvent.type(input, 'pa$$w0rd');
const revealButton = canvas.getByTestId('reveal-password-button');
await userEvent.click(revealButton);
expect(input).toHaveAttribute('type', 'text');
},
};