feat: address composite field (#4492)

Added new Address field input type.

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
rostaklein
2024-03-28 16:50:38 +01:00
committed by GitHub
parent 22d4af2e0c
commit 3171d0c87b
56 changed files with 1839 additions and 716 deletions

View File

@ -1,8 +1,10 @@
import { FunctionComponent } from 'react';
export type IconComponent = FunctionComponent<{
export type IconComponentProps = {
className?: string;
color?: string;
size?: number;
stroke?: number;
}>;
};
export type IconComponent = FunctionComponent<IconComponentProps>;

View File

@ -0,0 +1,254 @@
import { RefObject, useEffect, useRef, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { flip, offset, useFloating } from '@floating-ui/react';
import { Key } from 'ts-key-enum';
import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue';
import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata';
import { CountrySelect } from '@/ui/input/components/internal/country/components/CountrySelect';
import { TextInput } from '@/ui/input/components/TextInput';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { isDefined } from '~/utils/isDefined';
const StyledAddressContainer = 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};
padding: 4px 8px;
width: 100%;
min-width: 260px;
> div {
margin-bottom: 6px;
}
`;
const StyledHalfRowContainer = styled.div`
display: flex;
gap: 8px;
`;
export type AddressInputProps = {
value: FieldAddressValue;
onTab: (newAddress: FieldAddressDraftValue) => void;
onShiftTab: (newAddress: FieldAddressDraftValue) => void;
onEnter: (newAddress: FieldAddressDraftValue) => void;
onEscape: (newAddress: FieldAddressDraftValue) => void;
onClickOutside: (
event: MouseEvent | TouchEvent,
newAddress: FieldAddressDraftValue,
) => void;
hotkeyScope: string;
clearable?: boolean;
onChange?: (updatedValue: FieldAddressDraftValue) => void;
};
export const AddressInput = ({
value,
hotkeyScope,
onTab,
onShiftTab,
onEnter,
onEscape,
onClickOutside,
onChange,
}: AddressInputProps) => {
const theme = useTheme();
const [internalValue, setInternalValue] = useState(value);
const addressStreet1InputRef = useRef<HTMLInputElement>(null);
const addressStreet2InputRef = useRef<HTMLInputElement>(null);
const addressCityInputRef = useRef<HTMLInputElement>(null);
const addressStateInputRef = useRef<HTMLInputElement>(null);
const addressPostCodeInputRef = useRef<HTMLInputElement>(null);
const inputRefs: {
[key in keyof FieldAddressDraftValue]?: RefObject<HTMLInputElement>;
} = {
addressStreet1: addressStreet1InputRef,
addressStreet2: addressStreet2InputRef,
addressCity: addressCityInputRef,
addressState: addressStateInputRef,
addressPostcode: addressPostCodeInputRef,
};
const [focusPosition, setFocusPosition] =
useState<keyof FieldAddressDraftValue>('addressStreet1');
const wrapperRef = useRef<HTMLDivElement>(null);
const { refs, floatingStyles } = useFloating({
placement: 'top-start',
middleware: [
flip(),
offset({
mainAxis: theme.spacingMultiplicator * 2,
}),
],
});
const getChangeHandler =
(field: keyof FieldAddressDraftValue) => (updatedAddressPart: string) => {
const updatedAddress = { ...value, [field]: updatedAddressPart };
setInternalValue(updatedAddress);
onChange?.(updatedAddress);
};
const getFocusHandler = (fieldName: keyof FieldAddressDraftValue) => () => {
setFocusPosition(fieldName);
inputRefs[fieldName]?.current?.focus();
};
useScopedHotkeys(
'tab',
() => {
const currentFocusPosition = Object.keys(inputRefs).findIndex(
(key) => key === focusPosition,
);
const maxFocusPosition = Object.keys(inputRefs).length - 1;
const nextFocusPosition = currentFocusPosition + 1;
const isFocusPositionAfterLast = nextFocusPosition > maxFocusPosition;
if (isFocusPositionAfterLast) {
onTab?.(internalValue);
} else {
const nextFocusFieldName = Object.keys(inputRefs)[
nextFocusPosition
] as keyof FieldAddressDraftValue;
setFocusPosition(nextFocusFieldName);
inputRefs[nextFocusFieldName]?.current?.focus();
}
},
hotkeyScope,
[onTab, internalValue, focusPosition],
);
useScopedHotkeys(
'shift+tab',
() => {
const currentFocusPosition = Object.keys(inputRefs).findIndex(
(key) => key === focusPosition,
);
const nextFocusPosition = currentFocusPosition - 1;
const isFocusPositionBeforeFirst = nextFocusPosition < 0;
if (isFocusPositionBeforeFirst) {
onShiftTab?.(internalValue);
} else {
const nextFocusFieldName = Object.keys(inputRefs)[
nextFocusPosition
] as keyof FieldAddressDraftValue;
setFocusPosition(nextFocusFieldName);
inputRefs[nextFocusFieldName]?.current?.focus();
}
},
hotkeyScope,
[onTab, internalValue, focusPosition],
);
useScopedHotkeys(
Key.Enter,
() => {
onEnter(internalValue);
},
hotkeyScope,
[onEnter, internalValue],
);
useScopedHotkeys(
[Key.Escape],
() => {
onEscape(internalValue);
},
hotkeyScope,
[onEscape, internalValue],
);
const { useListenClickOutside } = useClickOutsideListener('addressInput');
useListenClickOutside({
refs: [wrapperRef],
callback: (event) => {
event.stopImmediatePropagation();
onClickOutside?.(event, internalValue);
},
enabled: isDefined(onClickOutside),
});
useEffect(() => {
setInternalValue(value);
}, [value]);
return (
<div ref={refs.setFloating} style={floatingStyles}>
<StyledAddressContainer ref={wrapperRef}>
<TextInput
autoFocus
value={internalValue.addressStreet1 ?? ''}
ref={inputRefs['addressStreet1']}
label="ADDRESS 1"
fullWidth
onChange={getChangeHandler('addressStreet1')}
onFocus={getFocusHandler('addressStreet1')}
disableHotkeys
/>
<TextInput
value={internalValue.addressStreet2 ?? ''}
ref={inputRefs['addressStreet2']}
label="ADDRESS 2"
fullWidth
onChange={getChangeHandler('addressStreet2')}
onFocus={getFocusHandler('addressStreet2')}
disableHotkeys
/>
<StyledHalfRowContainer>
<TextInput
value={internalValue.addressCity ?? ''}
ref={inputRefs['addressCity']}
label="CITY"
fullWidth
onChange={getChangeHandler('addressCity')}
onFocus={getFocusHandler('addressCity')}
disableHotkeys
/>
<TextInput
value={internalValue.addressState ?? ''}
ref={inputRefs['addressState']}
label="STATE"
fullWidth
onChange={getChangeHandler('addressState')}
onFocus={getFocusHandler('addressState')}
disableHotkeys
/>
</StyledHalfRowContainer>
<StyledHalfRowContainer>
<TextInput
value={internalValue.addressPostcode ?? ''}
ref={inputRefs['addressPostcode']}
label="POST CODE"
fullWidth
onChange={getChangeHandler('addressPostcode')}
onFocus={getFocusHandler('addressPostcode')}
disableHotkeys
/>
<CountrySelect
onChange={getChangeHandler('addressCountry')}
selectedCountryName={internalValue.addressCountry ?? ''}
/>
</StyledHalfRowContainer>
</StyledAddressContainer>
</div>
);
};

View File

@ -13,7 +13,7 @@ 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';
import { StyledTextInput } from './TextInput';
const StyledContainer = styled.div`
align-items: center;
@ -174,7 +174,7 @@ export const DoubleTextInput = ({
return (
<StyledContainer ref={containerRef}>
<StyledInput
<StyledTextInput
autoComplete="off"
autoFocus
onFocus={() => setFocusPosition('left')}
@ -188,7 +188,7 @@ export const DoubleTextInput = ({
handleOnPaste(event)
}
/>
<StyledInput
<StyledTextInput
autoComplete="off"
onFocus={() => setFocusPosition('right')}
ref={secondValueInputRef}

View File

@ -3,7 +3,7 @@ import ReactPhoneNumberInput from 'react-phone-number-input';
import styled from '@emotion/styled';
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
import { CountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/CountryPickerDropdownButton';
import { PhoneCountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton';
import 'react-phone-number-input/style.css';
@ -102,7 +102,7 @@ export const PhoneInput = ({
onChange={handleChange}
international={true}
withCountryCallingCode={true}
countrySelectComponent={CountryPickerDropdownButton}
countrySelectComponent={PhoneCountryPickerDropdownButton}
/>
</StyledContainer>
);

View File

@ -4,7 +4,7 @@ import styled from '@emotion/styled';
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
import { TEXT_INPUT_STYLE } from '@/ui/theme/constants/TextInputStyle';
export const StyledInput = styled.input`
export const StyledTextInput = styled.input`
margin: 0;
${TEXT_INPUT_STYLE}
width: 100%;
@ -60,7 +60,7 @@ export const TextInput = ({
});
return (
<StyledInput
<StyledTextInput
autoComplete="off"
ref={wrapperRef}
placeholder={placeholder}

View File

@ -1,7 +1,7 @@
import { ChangeEvent } from 'react';
import styled from '@emotion/styled';
import { StyledInput } from '@/ui/field/input/components/TextInput';
import { StyledTextInput as UIStyledTextInput } from '@/ui/field/input/components/TextInput';
import { ComputeNodeDimensions } from '@/ui/utilities/dimensions/components/ComputeNodeDimensions';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
@ -23,7 +23,7 @@ const StyledDoubleTextContainer = styled.div`
text-align: center;
`;
const StyledTextInput = styled(StyledInput)`
const StyledTextInput = styled(UIStyledTextInput)`
margin: 0 ${({ theme }) => theme.spacing(0.5)};
padding: 0;
width: ${({ width }) => (width ? `${width}px` : 'auto')};

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
@ -10,6 +10,7 @@ import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/Dropdow
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
@ -43,6 +44,7 @@ const StyledControlContainer = styled.div<{ disabled?: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
box-sizing: border-box;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ disabled, theme }) =>
disabled ? theme.font.color.tertiary : theme.font.color.primary};
@ -88,6 +90,8 @@ export const Select = <Value extends string | number | null>({
value,
withSearchInput,
}: SelectProps<Value>) => {
const selectContainerRef = useRef<HTMLDivElement>(null);
const theme = useTheme();
const [searchInputValue, setSearchInputValue] = useState('');
@ -109,6 +113,15 @@ export const Select = <Value extends string | number | null>({
const { closeDropdown } = useDropdown(dropdownId);
const { useListenClickOutside } = useClickOutsideListener(dropdownId);
useListenClickOutside({
refs: [selectContainerRef],
callback: () => {
closeDropdown();
},
});
const selectControl = (
<StyledControlContainer disabled={isDisabled}>
<StyledControlLabel>
@ -133,6 +146,7 @@ export const Select = <Value extends string | number | null>({
fullWidth={fullWidth}
tabIndex={0}
onBlur={onBlur}
ref={selectContainerRef}
>
{!!label && <StyledLabel>{label}</StyledLabel>}
{isDisabled ? (

View File

@ -20,20 +20,6 @@ import { useCombinedRefs } from '~/hooks/useCombinedRefs';
import { InputHotkeyScope } from '../types/InputHotkeyScope';
export type TextInputComponentProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'onKeyDown'
> & {
className?: string;
label?: string;
onChange?: (text: string) => void;
fullWidth?: boolean;
disableHotkeys?: boolean;
error?: string;
RightIcon?: IconComponent;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
};
const StyledContainer = styled.div<Pick<TextInputComponentProps, 'fullWidth'>>`
display: inline-flex;
flex-direction: column;
@ -110,6 +96,21 @@ const StyledTrailingIcon = styled.div`
const INPUT_TYPE_PASSWORD = 'password';
export type TextInputComponentProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'onKeyDown'
> & {
className?: string;
label?: string;
onChange?: (text: string) => void;
fullWidth?: boolean;
disableHotkeys?: boolean;
error?: string;
RightIcon?: IconComponent;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onBlur?: () => void;
};
const TextInputComponent = (
{
className,
@ -163,6 +164,7 @@ const TextInputComponent = (
inputRef.current?.blur();
},
InputHotkeyScope.TextInput,
{ enabled: !disableHotkeys },
);
const [passwordVisible, setPasswordVisible] = useState(false);

View File

@ -0,0 +1,37 @@
import { useMemo } from 'react';
import { IconComponentProps } from '@/ui/display/icon/types/IconComponent';
import { SELECT_COUNTRY_DROPDOWN_ID } from '@/ui/input/components/internal/country/constants/SelectCountryDropdownId';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
import { Select, SelectOption } from '@/ui/input/components/Select';
export const CountrySelect = ({
selectedCountryName,
onChange,
}: {
selectedCountryName: string;
onChange: (countryCode: string) => void;
}) => {
const countries = useCountries();
const options: SelectOption<string>[] = useMemo(() => {
return countries.map<SelectOption<string>>(({ countryName, Flag }) => ({
label: countryName,
value: countryName,
Icon: (props: IconComponentProps) =>
Flag({ width: props.size, height: props.size }), // TODO : improve this ?
}));
}, [countries]);
return (
<Select
fullWidth
dropdownId={SELECT_COUNTRY_DROPDOWN_ID}
options={options}
label="COUNTRY"
withSearchInput
onChange={onChange}
value={selectedCountryName}
/>
);
};

View File

@ -0,0 +1 @@
export const SELECT_COUNTRY_DROPDOWN_ID = 'select-country-picker';

View File

@ -0,0 +1,37 @@
import { useMemo } from 'react';
import { hasFlag } from 'country-flag-icons';
import * as Flags from 'country-flag-icons/react/3x2';
import { getCountries, getCountryCallingCode } from 'libphonenumber-js';
import { Country } from '@/ui/input/components/internal/types/Country';
export const useCountries = () => {
return useMemo<Country[]>(() => {
const regionNamesInEnglish = new Intl.DisplayNames(['en'], {
type: 'region',
});
const countryCodes = getCountries();
return countryCodes.reduce<Country[]>((result, countryCode) => {
const countryName = regionNamesInEnglish.of(countryCode);
if (!countryName) return result;
if (!hasFlag(countryCode)) return result;
const Flag = Flags[countryCode];
const callingCode = getCountryCallingCode(countryCode);
result.push({
countryCode,
countryName,
callingCode,
Flag,
});
return result;
}, []);
}, []);
};

View File

@ -1,19 +1,17 @@
import { useEffect, useMemo, useState } from 'react';
import { getCountries, getCountryCallingCode } from 'react-phone-number-input';
import { useEffect, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { hasFlag } from 'country-flag-icons';
import * as Flags from 'country-flag-icons/react/3x2';
import { CountryCallingCode } from 'libphonenumber-js';
import { IconChevronDown, IconWorld } from '@/ui/display/icon';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
import { Country } from '@/ui/input/components/internal/types/Country';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { isDefined } from '~/utils/isDefined';
import { CountryPickerHotkeyScope } from '../types/CountryPickerHotkeyScope';
import { CountryPickerDropdownSelect } from './CountryPickerDropdownSelect';
import { PhoneCountryPickerDropdownSelect } from './PhoneCountryPickerDropdownSelect';
import 'react-phone-number-input/style.css';
@ -57,14 +55,7 @@ const StyledIconContainer = styled.div`
}
`;
export type Country = {
countryCode: string;
countryName: string;
callingCode: CountryCallingCode;
Flag: Flags.FlagComponent;
};
export const CountryPickerDropdownButton = ({
export const PhoneCountryPickerDropdownButton = ({
value,
onChange,
}: {
@ -82,34 +73,7 @@ export const CountryPickerDropdownButton = ({
closeDropdown();
};
const countries = useMemo<Country[]>(() => {
const regionNamesInEnglish = new Intl.DisplayNames(['en'], {
type: 'region',
});
const countryCodes = getCountries();
return countryCodes.reduce<Country[]>((result, countryCode) => {
const countryName = regionNamesInEnglish.of(countryCode);
if (!countryName) return result;
if (!hasFlag(countryCode)) return result;
const Flag = Flags[countryCode];
const callingCode = getCountryCallingCode(countryCode);
result.push({
countryCode,
countryName,
callingCode,
Flag,
});
return result;
}, []);
}, []);
const countries = useCountries();
useEffect(() => {
const country = countries.find(({ countryCode }) => countryCode === value);
@ -132,7 +96,7 @@ export const CountryPickerDropdownButton = ({
</StyledDropdownButtonContainer>
}
dropdownComponents={
<CountryPickerDropdownSelect
<PhoneCountryPickerDropdownSelect
countries={countries}
selectedCountry={selectedCountry}
onChange={handleChange}

View File

@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import styled from '@emotion/styled';
import { Country } from '@/ui/input/components/internal/types/Country';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
@ -8,8 +9,6 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemSelectAvatar';
import { Country } from './CountryPickerDropdownButton';
import 'react-phone-number-input/style.css';
const StyledIconContainer = styled.div`
@ -27,7 +26,7 @@ const StyledIconContainer = styled.div`
}
`;
export const CountryPickerDropdownSelect = ({
export const PhoneCountryPickerDropdownSelect = ({
countries,
selectedCountry,
onChange,

View File

@ -0,0 +1,9 @@
import * as Flags from 'country-flag-icons/react/3x2';
import { CountryCallingCode } from 'libphonenumber-js';
export type Country = {
countryCode: string;
countryName: string;
callingCode: CountryCallingCode;
Flag: Flags.FlagComponent;
};