feat: add modal
This commit is contained in:
@@ -4,6 +4,7 @@ import { Montserrat, Roboto } from 'next/font/google';
|
||||
import '@core/styles/globals.scss';
|
||||
import '@core/styles/reset.scss';
|
||||
import { Footer, Header } from '@/widgets';
|
||||
import { ModalProvider } from '@core/providers/modal-provider';
|
||||
|
||||
const roboto = Roboto({
|
||||
subsets: ['cyrillic'],
|
||||
@@ -30,7 +31,9 @@ export default function RootLayout({
|
||||
<html lang='en'>
|
||||
<body className={`${roboto.variable} ${montseratt.variable}`}>
|
||||
<Header />
|
||||
<ModalProvider>
|
||||
<main>{children}</main>
|
||||
</ModalProvider>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
41
src/core/providers/modal-provider.tsx
Normal file
41
src/core/providers/modal-provider.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useState,
|
||||
useContext,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
createContext,
|
||||
} from 'react';
|
||||
import { Modal } from '@shared/ui/modal';
|
||||
|
||||
const ModalContext = createContext({
|
||||
hideModal: () => {},
|
||||
showModal: (content: ReactNode) => {},
|
||||
});
|
||||
|
||||
const useModal = () => useContext(ModalContext);
|
||||
|
||||
const ModalProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [modalContent, setModalContent] = useState<ReactNode>(null);
|
||||
|
||||
const showModal = useCallback((content: ReactNode) => {
|
||||
setModalContent(content);
|
||||
}, []);
|
||||
|
||||
const hideModal = useCallback(() => {
|
||||
setModalContent(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={{ hideModal, showModal }}>
|
||||
{children}
|
||||
{/* Ваш Modal компонент здесь */}
|
||||
<Modal isOpen={modalContent !== null} onClose={hideModal}>
|
||||
{modalContent}
|
||||
</Modal>
|
||||
</ModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { useModal, ModalProvider };
|
||||
@@ -24,6 +24,7 @@ $color-darkgray: #999999;
|
||||
$color-text: #333333;
|
||||
$color-text-light: #777777;
|
||||
$color-green: #23A455;
|
||||
$color-green-hover: #23A455C2;
|
||||
$color-link: #333333;
|
||||
$color-link-hover: #009283;
|
||||
$color-error: #ff0000;
|
||||
|
||||
1
src/feature/article/consultation-modal/index.ts
Normal file
1
src/feature/article/consultation-modal/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui';
|
||||
46
src/feature/article/consultation-modal/styles.module.scss
Normal file
46
src/feature/article/consultation-modal/styles.module.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
.Form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: rem(16px);
|
||||
|
||||
@include iflaptop {
|
||||
gap: rem(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.Title {
|
||||
font-family: $font-roboto;
|
||||
font-weight: $font-medium;
|
||||
font-size: rem(20px);
|
||||
line-height: 130%;
|
||||
color: $color-text;
|
||||
|
||||
@include ifdesktop {
|
||||
font-size: rem(24px);
|
||||
}
|
||||
}
|
||||
|
||||
.Description {
|
||||
font-family: $font-roboto;
|
||||
font-weight: $font-regular;
|
||||
font-size: rem(16px);
|
||||
line-height: 100%;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.Agreement {
|
||||
font-family: $font-roboto;
|
||||
font-weight: $font-regular;
|
||||
font-size: rem(14px);
|
||||
line-height: 100%;
|
||||
color: $color-text;
|
||||
|
||||
a {
|
||||
color: $color-green;
|
||||
|
||||
&:hover {
|
||||
color: $color-green;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
145
src/feature/article/consultation-modal/ui.tsx
Normal file
145
src/feature/article/consultation-modal/ui.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import s from './styles.module.scss';
|
||||
import { Button, Input } from '@/shared/ui';
|
||||
import { PhoneInput, TextArea } from '@shared/ui';
|
||||
import { z } from 'zod';
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js/min';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { sendFormFn } from '@shared/api/api.service';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ModalContent } from '@shared/ui/modal/modal-content';
|
||||
import { useModal } from '@core/providers/modal-provider';
|
||||
import Link from 'next/link';
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(3, { message: 'Поле должно содержать не менее 3-х букв' })
|
||||
.regex(/^[A-Za-zА-Яа-яЁё]+(?:[ '-][A-Za-zА-Яа-яЁё]+)*$/, {
|
||||
message: 'Поле содержит некорректные символы',
|
||||
}),
|
||||
phone: z.string().refine(isValidPhoneNumber, 'Некорректный номер телефона'),
|
||||
message: z
|
||||
.string()
|
||||
.min(21, { message: 'Оставьте сообщение мин. 20 символов' }),
|
||||
});
|
||||
type TForm = z.infer<typeof FormSchema>;
|
||||
|
||||
const defaultValues = {
|
||||
name: '',
|
||||
phone: '',
|
||||
message: '',
|
||||
};
|
||||
|
||||
type ConsultationModalProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function ConsultationModal({}: ConsultationModalProps) {
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
clearErrors,
|
||||
formState: { errors },
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const modal = useModal();
|
||||
|
||||
const onSubmit = async (data: TForm) => {
|
||||
const payload = {
|
||||
...data,
|
||||
form: 'consultation-modal-form',
|
||||
};
|
||||
|
||||
try {
|
||||
await sendFormFn(payload);
|
||||
toast.success('Заявка на консультацию принята');
|
||||
} catch (e) {
|
||||
toast.error('Ошибка при отправке заявки...', {
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
modal.hideModal();
|
||||
reset(defaultValues);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalContent closeByClickOutside>
|
||||
<form
|
||||
className={s.Form}
|
||||
id='consultation-modal-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<h2 className={s.Title}>
|
||||
Мы подскажем, как решить ваши вопросы по пожарной безопасности.
|
||||
</h2>
|
||||
<Controller
|
||||
control={control}
|
||||
name={'name'}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={'Ваше имя'}
|
||||
fullWidth
|
||||
onChange={(e) => {
|
||||
clearErrors('name');
|
||||
field.onChange(e);
|
||||
}}
|
||||
error={errors && errors.name?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name={'phone'}
|
||||
render={({ field }) => (
|
||||
<PhoneInput
|
||||
{...field}
|
||||
placeholder={'+7 (999) 123-45-67'}
|
||||
fullWidth
|
||||
onChange={(e) => {
|
||||
clearErrors('phone');
|
||||
field.onChange(e);
|
||||
}}
|
||||
error={errors && errors.phone?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className={s.Description}>Кратко опишите интересующий Вас вопрос</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name={'message'}
|
||||
render={({ field }) => (
|
||||
<TextArea
|
||||
{...field}
|
||||
placeholder={''}
|
||||
fullWidth
|
||||
id='story'
|
||||
name='story'
|
||||
rows={6}
|
||||
error={errors && errors.message?.message}
|
||||
errorTextColor={'#ff9191'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className={s.Agreement}>
|
||||
Нажимая на кнопку, вы даете согласие на обработку своих персональных
|
||||
данных и соглашаетесь с
|
||||
<Link href={'/privacy-policy'}> Политикой конфиденциальности</Link>
|
||||
</span>
|
||||
<Button className={s.SendBtn} variant='green'>
|
||||
Отправить
|
||||
</Button>
|
||||
</form>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export { ConsultationModal };
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './related-articles';
|
||||
export * from './consultation';
|
||||
export * from './sidebar';
|
||||
export * from './consultation-modal';
|
||||
|
||||
@@ -90,6 +90,14 @@
|
||||
line-height: 130%;
|
||||
color: $color-text;
|
||||
list-style: unset;
|
||||
|
||||
a {
|
||||
color: $color-green;
|
||||
}
|
||||
a:hover {
|
||||
color: $color-green-hover;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import s from './styles.module.scss';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@shared/ui';
|
||||
import { TSidebar } from '@shared/types/sidebar';
|
||||
import { useModal } from '@core/providers/modal-provider';
|
||||
import { ConsultationModal } from '@/feature/article';
|
||||
import { CONTACTS } from '@shared/const/contacts';
|
||||
|
||||
type SidebarProps = TSidebar;
|
||||
|
||||
@@ -12,6 +17,11 @@ function Sidebar({
|
||||
warrantiesTitle,
|
||||
warranties,
|
||||
}: SidebarProps) {
|
||||
const modal = useModal();
|
||||
const openModal = () => modal.showModal(<ConsultationModal />);
|
||||
|
||||
const callTo = `tel:${CONTACTS.PHONE}`;
|
||||
|
||||
return (
|
||||
<aside className={s.Sidebar}>
|
||||
<div className={s.Estimation}>
|
||||
@@ -22,8 +32,12 @@ function Sidebar({
|
||||
определения точной стоимости.
|
||||
</p>
|
||||
<p className={s.Text}>Оставьте заявку или позвоните по телефону</p>
|
||||
<Button variant={'white'}>Записаться</Button>
|
||||
<Button variant={'white'} onClick={openModal}>
|
||||
Записаться
|
||||
</Button>
|
||||
<a href={callTo}>
|
||||
<p className={s.Phone}>+7 (900) 241-34-34</p>
|
||||
</a>
|
||||
</div>
|
||||
{related && (
|
||||
<div className={s.Related}>
|
||||
|
||||
1
src/shared/lib/clickOutside/index.ts
Normal file
1
src/shared/lib/clickOutside/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useClickOutside } from './useClickOutside';
|
||||
28
src/shared/lib/clickOutside/useClickOutside.tsx
Normal file
28
src/shared/lib/clickOutside/useClickOutside.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
|
||||
export function useClickOutside(
|
||||
ref: RefObject<HTMLDivElement | null>,
|
||||
fn: (event: Event) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: Event) => {
|
||||
const el = ref?.current;
|
||||
if (event instanceof MouseEvent && window !== undefined) {
|
||||
const x = event?.offsetX || 0;
|
||||
const width = window?.innerWidth - 18;
|
||||
if (x >= width) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!el || el.contains((event?.target as Node) || null)) {
|
||||
return;
|
||||
}
|
||||
fn(event);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [ref, fn]);
|
||||
}
|
||||
15
src/shared/lib/detectIOS/detectIOS.ts
Normal file
15
src/shared/lib/detectIOS/detectIOS.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
function detectIOS() {
|
||||
const iosQuirkPresent = () => {
|
||||
const audio = new Audio();
|
||||
audio.volume = 0.5;
|
||||
return audio.volume === 1; // volume cannot be changed from "1" on iOS 12 and below
|
||||
};
|
||||
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
const isAppleDevice = navigator.userAgent.includes('Macintosh');
|
||||
const isTouchScreen = navigator.maxTouchPoints >= 1; // true for iOS 13 (and hopefully beyond)
|
||||
|
||||
return isIOS || (isAppleDevice && (isTouchScreen || iosQuirkPresent()));
|
||||
}
|
||||
|
||||
export { detectIOS };
|
||||
1
src/shared/lib/detectIOS/index.ts
Normal file
1
src/shared/lib/detectIOS/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { detectIOS } from './detectIOS';
|
||||
2
src/shared/lib/index.ts
Normal file
2
src/shared/lib/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useClickOutside } from './clickOutside';
|
||||
export { detectIOS } from './detectIOS';
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './input';
|
||||
export * from './phone-input';
|
||||
export * from './button';
|
||||
export * from './modal';
|
||||
export * from './text-area';
|
||||
|
||||
23
src/shared/ui/modal/close-icon.tsx
Normal file
23
src/shared/ui/modal/close-icon.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
const CloseIcon = ({ ...props }: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
width='17'
|
||||
height='17'
|
||||
viewBox='0 0 17 17'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M2.5 15L15.5 2M2.5 2L15.5 15'
|
||||
stroke='#053635'
|
||||
strokeOpacity='0.3'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { CloseIcon };
|
||||
2
src/shared/ui/modal/index.ts
Normal file
2
src/shared/ui/modal/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Modal } from './modal';
|
||||
export { ModalContent } from './modal-content';
|
||||
79
src/shared/ui/modal/modal-content.module.scss
Normal file
79
src/shared/ui/modal/modal-content.module.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
.ModalContent {
|
||||
position: absolute;
|
||||
width: rem(360px);
|
||||
padding: rem(40px) rem(20px) rem(20px);
|
||||
border-radius: rem(20px);
|
||||
background: white;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
overflow: auto;
|
||||
animation: fadeIn ease 0.3s;
|
||||
|
||||
@include iftablet {
|
||||
top: unset;
|
||||
width: rem(540px);
|
||||
padding: rem(40px) rem(24px) rem(24px);
|
||||
}
|
||||
|
||||
@include iflaptop {
|
||||
padding: rem(40px) rem(30px) rem(30px);
|
||||
}
|
||||
|
||||
@include ifdesktop {
|
||||
width: rem(640px);
|
||||
padding: rem(60px) rem(40px) rem(40px);
|
||||
}
|
||||
|
||||
&_open {
|
||||
animation: fadeOut ease 0.3s;
|
||||
}
|
||||
|
||||
&_isIOS {
|
||||
height: 100vh !important;
|
||||
|
||||
@include iftablet {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.Inner {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.CloseBtn {
|
||||
position: absolute;
|
||||
top: rem(20px);
|
||||
right: rem(20px);
|
||||
width: rem(24px);
|
||||
height: rem(24px);
|
||||
cursor: pointer;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.3s;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
transform: rotate(90deg);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
49
src/shared/ui/modal/modal-content.tsx
Normal file
49
src/shared/ui/modal/modal-content.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import s from './modal-content.module.scss';
|
||||
import { MouseEvent, ReactNode, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useModal } from '@core/providers/modal-provider';
|
||||
import { detectIOS, useClickOutside } from '@shared/lib';
|
||||
import { CloseIcon } from '@shared/ui/modal/close-icon';
|
||||
|
||||
function ModalContent(props: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
innerClassName?: string;
|
||||
closeByClickOutside?: boolean;
|
||||
}) {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
innerClassName,
|
||||
closeByClickOutside = false,
|
||||
} = props;
|
||||
|
||||
const modal = useModal();
|
||||
|
||||
const hideModal = () => modal.hideModal();
|
||||
|
||||
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleClickOutside = () => closeByClickOutside && hideModal();
|
||||
|
||||
useClickOutside(modalRef, handleClickOutside);
|
||||
|
||||
const isIOS = detectIOS();
|
||||
|
||||
const disableClick = (e: MouseEvent<HTMLDivElement>) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(className, s.ModalContent, isIOS && s.ModalContent_isIOS)}
|
||||
ref={modalRef}
|
||||
onClick={disableClick}
|
||||
>
|
||||
<CloseIcon className={s.CloseBtn} onClick={hideModal} />
|
||||
<div className={clsx(s.Inner, innerClassName)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ModalContent };
|
||||
54
src/shared/ui/modal/modal.module.scss
Normal file
54
src/shared/ui/modal/modal.module.scss
Normal file
@@ -0,0 +1,54 @@
|
||||
.ModalBackdrop {
|
||||
z-index: 1000;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 0;
|
||||
animation: fadeIn ease 0.3s;
|
||||
|
||||
@include iftablet {
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
padding: 0 rem(20px);
|
||||
}
|
||||
|
||||
@include iflaptop {
|
||||
padding: 0 rem(48px);
|
||||
}
|
||||
|
||||
&_open {
|
||||
animation: fadeOut ease 0.3s;
|
||||
}
|
||||
|
||||
&_isIOS {
|
||||
height: 150vh !important;
|
||||
|
||||
@include iftablet {
|
||||
height: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
44
src/shared/ui/modal/modal.tsx
Normal file
44
src/shared/ui/modal/modal.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import s from './modal.module.scss';
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { detectIOS } from '@shared/lib';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Modal = ({ children, isOpen, onClose }: ModalProps) => {
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
if (isOpen) {
|
||||
html.style.overflow = 'hidden';
|
||||
}
|
||||
return () => {
|
||||
html.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isIOS = detectIOS();
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={clsx(
|
||||
s.ModalBackdrop,
|
||||
isOpen && s.ModalBackdrop__open,
|
||||
isIOS && s.ModalBackdrop_isIOS,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export { Modal };
|
||||
1
src/shared/ui/text-area/index.ts
Normal file
1
src/shared/ui/text-area/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui';
|
||||
139
src/shared/ui/text-area/styles.module.scss
Normal file
139
src/shared/ui/text-area/styles.module.scss
Normal file
@@ -0,0 +1,139 @@
|
||||
.Container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
/* Allows the `resize` property to work on the div */
|
||||
overflow: hidden;
|
||||
|
||||
&_fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.Area {
|
||||
background: $color-white;
|
||||
border: 1px solid $color-darkgray;
|
||||
border-radius: rem(16px);
|
||||
padding: rem(10px) rem(10px);
|
||||
transition: border ease .5s;
|
||||
|
||||
font-family: $font-roboto;
|
||||
font-weight: $font-regular;
|
||||
font-size: rem(16px);
|
||||
line-height: 100%;
|
||||
color: $color-text;
|
||||
|
||||
resize: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
|
||||
@include iftablet {
|
||||
font-size: rem(18px);
|
||||
border-radius: rem(20px);
|
||||
padding: rem(10px) rem(24px);
|
||||
}
|
||||
|
||||
@include iflaptop {
|
||||
font-size: rem(20px);
|
||||
padding: rem(10px) rem(20px);
|
||||
}
|
||||
|
||||
@include ifdesktop {
|
||||
font-size: rem(24px);
|
||||
}
|
||||
|
||||
/* scrollbar */
|
||||
& {
|
||||
/* Arrow mouse cursor over the scrollbar */
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: rem(12px);
|
||||
height: rem(12px);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track,
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-clip: content-box;
|
||||
border: rem(4px) solid transparent;
|
||||
border-radius: rem(12px);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: #333; // цвет дорожки
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #999; // цвет указателя
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
:where(&:not(:disabled))::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
&:where(:autofill) {
|
||||
/* Reliably removes native autofill colors */
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: #999;
|
||||
}
|
||||
|
||||
|
||||
&:focus {
|
||||
border-color: $color-green;
|
||||
transition: border-color ease .5s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $color-text;
|
||||
transition: border-color ease .5s;
|
||||
}
|
||||
|
||||
&:focus:hover {
|
||||
border-color: $color-green;
|
||||
transition: border-color ease .5s;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&_ghost {
|
||||
background: transparent;
|
||||
color: $color-white;
|
||||
|
||||
&:focus {
|
||||
border-color: $color-green;
|
||||
transition: border-color ease .5s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $color-white;
|
||||
transition: border-color ease .5s;
|
||||
}
|
||||
|
||||
&:focus:hover {
|
||||
border-color: $color-green;
|
||||
transition: border-color ease .5s;
|
||||
}
|
||||
}
|
||||
|
||||
&_error {
|
||||
border: 1px solid $color-error;
|
||||
}
|
||||
}
|
||||
|
||||
.Error {
|
||||
margin-left: 8px;
|
||||
margin-top: 4px;
|
||||
font-family: $font-roboto;
|
||||
font-weight: $font-light;
|
||||
font-size: rem(12px);
|
||||
line-height: 100%;
|
||||
color: $color-error;
|
||||
}
|
||||
58
src/shared/ui/text-area/ui.tsx
Normal file
58
src/shared/ui/text-area/ui.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import s from './styles.module.scss';
|
||||
import {
|
||||
DetailedHTMLProps,
|
||||
forwardRef,
|
||||
ReactNode,
|
||||
Ref,
|
||||
TextareaHTMLAttributes,
|
||||
} from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
type TextAreaProps = {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
variant?: 'default' | 'ghost';
|
||||
fullWidth?: boolean;
|
||||
error?: string | boolean;
|
||||
errorTextColor?: string;
|
||||
} & DetailedHTMLProps<
|
||||
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
HTMLTextAreaElement
|
||||
>;
|
||||
|
||||
const TextArea = forwardRef(function TextArea(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
variant = 'default',
|
||||
fullWidth = false,
|
||||
error = false,
|
||||
errorTextColor,
|
||||
...props
|
||||
}: TextAreaProps,
|
||||
ref: Ref<HTMLTextAreaElement>,
|
||||
) {
|
||||
return (
|
||||
<div className={clsx(s.Container, fullWidth && s.Container_fullWidth)}>
|
||||
<textarea
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
className,
|
||||
s.Area,
|
||||
s['Area_' + variant],
|
||||
error && s.Area_error,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</textarea>
|
||||
{error && (
|
||||
<span className={s.Error} style={{ color: errorTextColor }}>
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { TextArea };
|
||||
Reference in New Issue
Block a user