feat: login form as a client component, theme toggle
This commit is contained in:
9
components/PhoneInput/PhoneInput.module.css
Normal file
9
components/PhoneInput/PhoneInput.module.css
Normal file
@ -0,0 +1,9 @@
|
||||
.country-select {
|
||||
font-size: medium;
|
||||
@mixin light {
|
||||
color: black;
|
||||
}
|
||||
@mixin dark {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
114
components/PhoneInput/PhoneInput.tsx
Normal file
114
components/PhoneInput/PhoneInput.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { IMaskInput } from "react-imask";
|
||||
import {
|
||||
InputBase,
|
||||
type InputBaseProps,
|
||||
type PolymorphicComponentProps,
|
||||
} from "@mantine/core";
|
||||
import { useUncontrolled } from "@mantine/hooks";
|
||||
import CountrySelect from "@/components/PhoneInput/components/CountrySelect";
|
||||
import getFormat from "@/components/PhoneInput/utils/getFormat";
|
||||
import getInitialDataFromValue from "@/components/PhoneInput/utils/getInitialDataFromValue";
|
||||
|
||||
export type Props = {
|
||||
initialCountryCode?: string;
|
||||
defaultValue?: string;
|
||||
} & Omit<
|
||||
PolymorphicComponentProps<typeof IMaskInput, InputBaseProps>,
|
||||
"onChange" | "defaultValue"
|
||||
> & { onChange: (value: string | null) => void };
|
||||
|
||||
const PhoneInput = ({
|
||||
initialCountryCode = "RU",
|
||||
value: _value,
|
||||
onChange: _onChange,
|
||||
defaultValue,
|
||||
...props
|
||||
}: Props) => {
|
||||
const [value, onChange] = useUncontrolled({
|
||||
value: _value,
|
||||
defaultValue,
|
||||
onChange: _onChange,
|
||||
});
|
||||
const initialData = useRef(
|
||||
getInitialDataFromValue(value, initialCountryCode)
|
||||
);
|
||||
const [country, setCountry] = useState(initialData.current.country);
|
||||
const [format, setFormat] = useState(initialData.current.format);
|
||||
const [localValue, setLocalValue] = useState(
|
||||
initialData.current.localValue
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const lastNotifiedValue = useRef<string | null>(value ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
const value = localValue.trim();
|
||||
if (value !== lastNotifiedValue.current) {
|
||||
lastNotifiedValue.current = value;
|
||||
onChange(value);
|
||||
}
|
||||
}, [country.code, localValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof value !== "undefined" &&
|
||||
value !== lastNotifiedValue.current
|
||||
) {
|
||||
const initialData = getInitialDataFromValue(
|
||||
value,
|
||||
initialCountryCode
|
||||
);
|
||||
lastNotifiedValue.current = value;
|
||||
setCountry(initialData.country);
|
||||
setFormat(initialData.format);
|
||||
setLocalValue(initialData.localValue);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const { readOnly, disabled } = props;
|
||||
const leftSectionWidth = 100;
|
||||
|
||||
return (
|
||||
<InputBase
|
||||
{...props}
|
||||
component={IMaskInput}
|
||||
inputRef={inputRef}
|
||||
leftSection={
|
||||
<CountrySelect
|
||||
disabled={disabled || readOnly}
|
||||
country={country}
|
||||
setCountry={country => {
|
||||
setCountry(country);
|
||||
setFormat(getFormat(country.code));
|
||||
setLocalValue("");
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}}
|
||||
leftSectionWidth={leftSectionWidth}
|
||||
/>
|
||||
}
|
||||
leftSectionWidth={leftSectionWidth}
|
||||
styles={{
|
||||
input: {
|
||||
fontSize: 17,
|
||||
color: "primaryColor.1",
|
||||
paddingLeft: `calc(${leftSectionWidth}px + var(--mantine-spacing-sm))`,
|
||||
},
|
||||
section: {
|
||||
borderRight:
|
||||
"1px solid var(--mantine-color-default-border)",
|
||||
},
|
||||
}}
|
||||
inputMode={"numeric"}
|
||||
mask={format.mask}
|
||||
value={localValue}
|
||||
onAccept={value => setLocalValue(value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhoneInput;
|
||||
137
components/PhoneInput/components/CountrySelect.tsx
Normal file
137
components/PhoneInput/components/CountrySelect.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
CheckIcon,
|
||||
Combobox,
|
||||
Flex,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Text,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import style from "@/components/PhoneInput/PhoneInput.module.css";
|
||||
import { Country } from "@/components/PhoneInput/types";
|
||||
import countryOptionsDataMap from "@/components/PhoneInput/utils/countryOptionsDataMap";
|
||||
|
||||
const countryOptionsData = Object.values(countryOptionsDataMap);
|
||||
|
||||
type Props = {
|
||||
country: Country;
|
||||
setCountry: (country: Country) => void;
|
||||
disabled: boolean | undefined;
|
||||
leftSectionWidth: number;
|
||||
};
|
||||
|
||||
const CountrySelect = ({
|
||||
country,
|
||||
setCountry,
|
||||
disabled,
|
||||
leftSectionWidth,
|
||||
}: Props) => {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => {
|
||||
combobox.resetSelectedOption();
|
||||
setSearch("");
|
||||
},
|
||||
|
||||
onDropdownOpen: () => {
|
||||
combobox.focusSearchInput();
|
||||
setTimeout(() => {
|
||||
selectedRef.current?.scrollIntoView({
|
||||
behavior: "instant",
|
||||
block: "center",
|
||||
});
|
||||
}, 0);
|
||||
},
|
||||
});
|
||||
|
||||
const options = countryOptionsData
|
||||
.filter(item =>
|
||||
item.name.toLowerCase().includes(search.toLowerCase().trim())
|
||||
)
|
||||
.map(item => (
|
||||
<Combobox.Option
|
||||
ref={item.code === country.code ? selectedRef : undefined}
|
||||
value={item.code}
|
||||
key={item.code}>
|
||||
<Group
|
||||
justify={"space-between"}
|
||||
wrap={"nowrap"}
|
||||
align={"center"}>
|
||||
<Text>
|
||||
{item.emoji} +{item.callingCode} | {item.name}
|
||||
</Text>
|
||||
{item.code === country.code && (
|
||||
<Box>
|
||||
<CheckIcon size={12} />
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
if (search) {
|
||||
combobox.selectFirstOption();
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
width={350}
|
||||
position={"bottom-start"}
|
||||
withArrow
|
||||
onOptionSubmit={val => {
|
||||
setCountry(countryOptionsDataMap[val]);
|
||||
combobox.closeDropdown();
|
||||
}}>
|
||||
<Combobox.Target withAriaAttributes={false}>
|
||||
<ActionIcon
|
||||
variant={"transparent"}
|
||||
onClick={() => combobox.toggleDropdown()}
|
||||
size={"lg"}
|
||||
tabIndex={-1}
|
||||
disabled={disabled}
|
||||
w={leftSectionWidth}>
|
||||
<Flex
|
||||
align={"center"}
|
||||
px={5}
|
||||
ml={"md"}
|
||||
w={"100%"}
|
||||
className={style["country-select"]}>
|
||||
<span>
|
||||
{country.emoji} +{country.callingCode}
|
||||
</span>
|
||||
</Flex>
|
||||
</ActionIcon>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Search
|
||||
value={search}
|
||||
onChange={event => setSearch(event.currentTarget.value)}
|
||||
placeholder="Поиск..."
|
||||
/>
|
||||
<Combobox.Options>
|
||||
<ScrollArea.Autosize
|
||||
mah={200}
|
||||
type="scroll">
|
||||
{options.length > 0 ? (
|
||||
options
|
||||
) : (
|
||||
<Combobox.Empty>Нет данных</Combobox.Empty>
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
export default CountrySelect;
|
||||
8
components/PhoneInput/types.ts
Normal file
8
components/PhoneInput/types.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import type { CountryCallingCode, CountryCode } from "libphonenumber-js";
|
||||
|
||||
export type Country = {
|
||||
code: CountryCode;
|
||||
name: string;
|
||||
emoji: string;
|
||||
callingCode: CountryCallingCode;
|
||||
};
|
||||
33
components/PhoneInput/utils/countryOptionsDataMap.ts
Normal file
33
components/PhoneInput/utils/countryOptionsDataMap.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import countries from "i18n-iso-countries";
|
||||
import ru from "i18n-iso-countries/langs/ru.json";
|
||||
import { type CountryCode, getCountries } from "libphonenumber-js";
|
||||
import getFlagEmoji from "@/components/PhoneInput/utils/getFlagEmoji";
|
||||
import { getCountryCallingCode } from "libphonenumber-js/max";
|
||||
import { Country } from "@/components/PhoneInput/types";
|
||||
|
||||
countries.registerLocale(ru);
|
||||
|
||||
const libIsoCountries = countries.getNames("ru", { select: "official" });
|
||||
const libPhoneNumberCountries = getCountries();
|
||||
|
||||
const countryOptionsDataMap = Object.fromEntries(
|
||||
libPhoneNumberCountries
|
||||
.map(code => {
|
||||
const name = libIsoCountries[code];
|
||||
const emoji = getFlagEmoji(code);
|
||||
if (!name || !emoji) return null;
|
||||
const callingCode = getCountryCallingCode(code);
|
||||
return [
|
||||
code,
|
||||
{
|
||||
code,
|
||||
name,
|
||||
emoji,
|
||||
callingCode,
|
||||
},
|
||||
] as [CountryCode, Country];
|
||||
})
|
||||
.filter(o => !!o)
|
||||
);
|
||||
|
||||
export default countryOptionsDataMap;
|
||||
8
components/PhoneInput/utils/getFlagEmoji.ts
Normal file
8
components/PhoneInput/utils/getFlagEmoji.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export default function getFlagEmoji(countryCode: string) {
|
||||
const FLAG_CODE_OFFSET = 127397;
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split("")
|
||||
.map(char => FLAG_CODE_OFFSET + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
}
|
||||
15
components/PhoneInput/utils/getFormat.ts
Normal file
15
components/PhoneInput/utils/getFormat.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { CountryCode, getExampleNumber } from "libphonenumber-js";
|
||||
import examples from "libphonenumber-js/examples.mobile.json";
|
||||
import { getCountryCallingCode } from "libphonenumber-js/max";
|
||||
|
||||
export default function getFormat(countryCode: CountryCode) {
|
||||
let example = getExampleNumber(
|
||||
countryCode,
|
||||
examples
|
||||
)!.formatInternational();
|
||||
const callingCode = getCountryCallingCode(countryCode);
|
||||
const callingCodeLen = callingCode.length + 1;
|
||||
example = example.slice(callingCodeLen);
|
||||
const mask = example.replace(/\d/g, "0");
|
||||
return { example, mask };
|
||||
}
|
||||
33
components/PhoneInput/utils/getInitialDataFromValue.ts
Normal file
33
components/PhoneInput/utils/getInitialDataFromValue.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Country } from "@/components/PhoneInput/types";
|
||||
import getFormat from "@/components/PhoneInput/utils/getFormat";
|
||||
import countryOptionsDataMap from "@/components/PhoneInput/utils/countryOptionsDataMap";
|
||||
import { CountryCode, parsePhoneNumberFromString } from "libphonenumber-js";
|
||||
|
||||
type InitialDataFromValue = {
|
||||
country: Country;
|
||||
format: ReturnType<typeof getFormat>;
|
||||
localValue: string;
|
||||
};
|
||||
|
||||
const getInitialDataFromValue = (
|
||||
value: string | undefined,
|
||||
initialCountryCode: string
|
||||
): InitialDataFromValue => {
|
||||
const defaultValue = {
|
||||
country: countryOptionsDataMap[initialCountryCode],
|
||||
format: getFormat(initialCountryCode as CountryCode),
|
||||
localValue: "",
|
||||
};
|
||||
if (!value) return defaultValue;
|
||||
const phoneNumber = parsePhoneNumberFromString(value);
|
||||
if (!phoneNumber) return defaultValue;
|
||||
if (!phoneNumber.country) return defaultValue;
|
||||
|
||||
return {
|
||||
country: countryOptionsDataMap[phoneNumber.country],
|
||||
localValue: phoneNumber.formatNational(),
|
||||
format: getFormat(phoneNumber.country),
|
||||
};
|
||||
}
|
||||
|
||||
export default getInitialDataFromValue;
|
||||
Reference in New Issue
Block a user