Универсальный компонент
На этом казалось бы можно закончить с модалкой, но мы упустили важный концепт касаемый универсальности компонента.
Button
Рассмотри этот концепт на примере кнопки, т.к. это будет очевиднее. И потом применим к модальному окну тот же подход
Создадим универсальную кнопку простым способом
import { ReactNode } from 'react'
type Props = {
children: ReactNode
onClick?: () => void
title?: string
type?: 'button' | 'submit' | 'reset'
}
export const Button = ({ children, onClick, title, type }: Props) => {
return (
<button onClick={onClick} title={title} type={type}>
{children}
</button>
)
}Но такой подход очень плохой, т.к. у кнопки сотни атрибутов и все их описывать является плохой идеей
ComponentProps
Теория
Есть различные способы получения стандартных пропсов HTML-элементов
ComponentPropsизвлекает все стандартные пропсы указанного HTML-элемента (в данном случае<button>)
type Props = ComponentProps<'button'>
export const Button = ({ ...rest }: Props) => {
return <button {...rest} />
}ComponentPropsWithRef- это расширениеComponentProps, которое добавляет поддержку рефов. Если вы используете React.forwardRef для компонента, то этот тип будет учитывать реф, ассоциированный с указанным элементом.
type Ref = HTMLButtonElement
type Props = ComponentPropsWithRef<'button'>
export const Button = forwardRef<Ref, Props>(({ ...rest }, ref) => {
return <button ref={ref} {...rest} />
})ComponentPropsWithoutRef— это тоже расширениеComponentProps, но оно явно исключает поддержку рефов. Это полезно, если вы хотите использовать все стандартные пропсы кнопки, но не хотите включать реф даже при использовании компонента с React.forwardRef.
type Props = ComponentPropsWithoutRef<'button'>
export const Button = ({ ...rest }: Props) => {
return <button {...rest} />
}Краткий итог
| Тип | Описание | Пример использования |
|---|---|---|
ComponentProps<'button'> | Извлекает все пропсы кнопки без рефов | Используется, когда рефы не нужны |
ComponentPropsWithRef<'button'> | Извлекает все пропсы кнопки с поддержкой рефов | Используется с React.forwardRef для поддержки рефов |
ComponentPropsWithoutRef<'button'> | Извлекает все пропсы кнопки и исключает рефы | Используется, когда рефы не нужны, даже с forwardRef |
Такой подход обеспечивает гибкость, позволяя разработчику использовать ваш компонент кнопки так же, как стандартный элемент кнопки, и при этом добавлять дополнительную функциональность.
Например, если вы хотите создать свою кнопку с дополнительными стилями, но при этом сохранить все возможности
стандартного элемента кнопки, вы можете это сделать, используя ComponentPropsWithoutRef.
Практика
Теперь кнопку можем расширять пропсами которые мы сами хотим добавить.
Согласно дизайну у кнопок есть несколько цветов. Быстро реализуем эту задачу
import { ComponentPropsWithoutRef } from 'react'
import s from './Button.module.css'
type Props = {
variant?: 'primary' | 'secondary' | 'outlined'
} & ComponentPropsWithoutRef<'button'>
export const Button = ({ variant = 'primary', className, ...rest }: Props) => {
return (
<button
className={`${s.button}
${variant === 'primary' ? s.primary : ''}
${variant === 'secondary' ? s.secondary : ''}
${variant === 'outlined' ? s.outlined : ''}
${className}
`}
{...rest}
/>
)
}.button {
all: unset;
cursor: pointer;
box-sizing: border-box;
color: var(--light-100);
font-family: inherit;
padding: 5px;
min-width: 100px;
text-align: center;
}
.primary {
background-color: var(--accent-500);
}
.secondary {
background-color: var(--dark-300);
}
.outlined {
color: var(--accent-500);
border: 1px solid var(--accent-500);
background-color: transparent;
}Результат. Заменим кнопки в компоненте DeletePostModal.tsx и убедимся, что кнопки отрабытвают верно и стили
применились 🚀

clsx
Запись определения стилей выглядит некрасиво. Воспользуемся популярной библиотекой для склеивания стилей clsx
pnpm add clsxexport const Button = ({ variant = 'primary', className, ...rest }: Props) => {
return <button className={clsx(s.button, s[variant], className)} {...rest} />
}Результат. Выглядит гораздо приятнее 🚀
Вывод. При создании универсальной компоненты мы обязаны сразу же залаживать атрибуты по умолчанию и делаем это при
помощи ComponentPropsWithoutRef или ComponentPropsWithRef
Modal
Согласно полученным знаниям сделаем компонент ModalRadix.tsx универсальным:
- добавим
ComponentPropsWithoutRef - уберем
children, т.к. он входит вComponentPropsWithoutRef - добавим свойство
size(для демонстрации возможностей)
import * as Dialog from '@radix-ui/react-dialog'
import { Cross2Icon } from '@radix-ui/react-icons'
import clsx from 'clsx'
import { ComponentPropsWithoutRef } from 'react'
import s from './ModalRadix.module.css'
type ModalSize = 'lg' | 'md' | 'sm'
type Props = {
open: boolean
onClose: () => void
modalTitle: string
size?: ModalSize
} & ComponentPropsWithoutRef<'div'>
export const ModalRadix = ({
size = 'md',
modalTitle,
onClose,
children,
className,
open,
...rest
}: Props) => (
<Dialog.Root open={open} onOpenChange={onClose} {...rest}>
<Dialog.Portal>
<Dialog.Overlay className={s.Overlay} />
<Dialog.Content className={clsx(s.Content, s[size], className)}>
<Dialog.Title className={s.Title}>{modalTitle}</Dialog.Title>
<hr />
{children}
<Dialog.Close asChild>
<button className={s.IconButton} aria-label="Close">
<Cross2Icon />
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)/*...*/
/* ❗ Удалите свойство max-width: 450px; */
.Content {
background-color: white;
border-radius: 6px;
box-shadow:
hsl(206 22% 7% / 35%) 0px 10px 38px -10px,
hsl(206 22% 7% / 20%) 0px 10px 20px -15px;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90vw;
max-height: 85vh;
padding: 25px;
animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
&:focus {
outline: none;
}
}
.sm {
width: 367px;
}
.md {
width: 532px;
}
.lg {
width: 764px;
}
/*...*/Результат. Попробуйте руками задать разные параметры sm и убедитесь, что все верно отрабатывает 🚀
Полиморфные компоненты
Посмотрите на компонент ModalRadix.tsx обратите внимание на атрибут asChild. Что это значит и зачем нужно ? 🤔
export const ModalRadix = (/*...*/) => (
/*...*/
<Dialog.Close asChild>
<button className={s.IconButton} aria-label="Close">
<Cross2Icon />
</button>
</Dialog.Close>
/*...*/
)Довольно часто дизайнеры рисует на макете кнопку, но эта кнопка должна отрабатывать как ссылка. Пример Sign up
Однако функциональность у них разная:
-
Кнопка обычно используется для выполнения действий, таких как отправка формы, запуск скриптов или других интерактивных элементов на странице (например, открытие модального окна).
-
Ссылка предназначена для навигации, перехода на другую страницу, раздел или внешний ресурс.
Компоненты, которые выглядят одинаково, но рендерят разные элементы называются полиморфными:
Для реализации полиморфных компонент в Radix UI используется Slot.
pnpm add @radix-ui/react-slotНаша кнопка будет выглядеть вот так
type Props = {
variant?: 'primary' | 'secondary' | 'outlined'
asChild?: boolean
} & ComponentPropsWithoutRef<'button'>
export const Button = ({ variant = 'primary', className, asChild, ...rest }: Props) => {
const Component = asChild ? Slot : 'button'
return <Component className={clsx(s.button, s[variant], className)} {...rest} />
}По умолчанию наши кнопки будут как и прежде рендерится как кнопки, но если нам нужна будет ссылка, то делаем вот так
export const Header = () => {
return (
<div className={s.headerWrapper}>
<div className={s.container}>
<h3>logotype</h3>
<Button asChild>
<a href="/sign-up">Sign Up</a>
</Button>
<Cart />
</div>
</div>
)
}Результат. И у нас рендерится вместо кнопки ссылка 🚀
