4 - Radix UI

RadixUI

Чтобы сделать универсальную модалку, необходимо реализовать:

  • Обработку клика за пределами модального окна
  • Закрытие по нажатию клавиши Escape
  • Поддержку порталов
  • Прием children для динамического содержимого
  • Стилизацию и кастомизацию: модалка должна поддерживать кастомизацию стилей
  • Доступность (a11y)
  • Управление фокусом: при открытии модального окна весь фокус должен быть внутри него.
  • Блокировку прокрутки

Чтобы реализовать модальное окно, согласно этим требования придется потрудиться не один день. Но в принципе все это реально сделать.

Но возьмем например dropdown. На первый взгляд кажется, что там делать. Для того чтобы глубже понять проблему реализации кастомных компонент посмотрите видео So You Think You Can Build A Dropdown?

И это касается многих элементов (селекты, чекбоксы, аккордироны, popover и много других). Мы можем их взять из MUI или других подобных библиотек, но есть определенные трудности:

  • Размер библиотеки: Использование Material-UI может увеличить размер вашего приложения из-за включения большого количества CSS и JavaScript кода для компонентов и их стилей.
  • Ограниченная кастомизация: В некоторых случаях может быть сложно добиться определенного внешнего вида или поведения компонентов Material-UI без изменения их внутреннего кода или использования обходных решений
  • Зависимость от дизайна Material Design: Если вы предпочитаете или ваш проект требует другого стиля дизайна, использование Material-UI может ограничить вашу свободу выбора.
  • Глубокое понинимание нюансов работы в Material Design

И вот тут нам на вырочку идет библиотека Radix UI

Radix UI — это библиотека компонентов React, предназначенная для создания интерфейсов с высококачественными, доступными и настраиваемыми UI-компонентами. Библиотека охватывает базовые элементы, такие как модальные окна, выпадающие списки, переключатели, диалоги, тултипы и другие. Radix UI решает множество задач по управлению интерфейсом и обеспечивает согласованную работу компонентов, особенно при создании доступных интерфейсов.

Основные особенности

  1. Высокая доступность (a11y):
  • Radix UI особое внимание уделяет доступности: все компоненты созданы с учетом стандартов ARIA, что делает их доступными для пользователей с ограниченными возможностями. Библиотека автоматически добавляет нужные атрибуты и управляет фокусом.
  • Например, модальное окно Dialog из Radix UI автоматически настроено для перемещения фокуса внутрь компонента при открытии и его возврата обратно при закрытии.
  1. Полный контроль над стилями:
  • В отличие от многих UI-библиотек, Radix UI предоставляет только функциональные компоненты без предустановленных стилей. Это делает библиотеку отличным выбором для проектов, где важен полный контроль над внешним видом, так как стилизация полностью ложится на разработчика.
  • Компоненты можно стилизовать с нуля с помощью любой CSS-системы (например, styled-components, Emotion, Tailwind CSS).
  1. Модульность и составные компоненты:
  • Компоненты Radix UI построены по принципу составных компонентов, что позволяет гибко управлять компонентами на уровне их частей. Например, компонент DropdownMenu предоставляет несколько подпунктов (элементы меню, триггеры, списки и т.д.) , которые можно комбинировать в нужной конфигурации.
  1. Хорошая производительность:
  • Radix UI создавался с акцентом на производительность и минимализм. Компоненты не добавляют избыточного кода и работают эффективно, поскольку основаны на функциональных хуках React.

Пример использования компонента Dialog (Модальное окно)

Идем по документации и внедряем модальное окно используя Radix UI

Первым дело установим компонент в наше приложение

Terminal
pnpm add @radix-ui/react-dialog
 

Создадим компонент ModalRadix.tsx который просто скопируем из документации

  • Выберите в документации CSS Modules
  • Создайте файл ModalRadix.module.css
  • Вставьте стили из документации
ModalRadix.tsx
import * as Dialog from '@radix-ui/react-dialog'
import { Cross2Icon } from '@radix-ui/react-icons'
import styles from './ModalRadix.module.css'
 
export const ModalRadix = () => (
  /*Берем код полностью из документации*/
)

Colors / Icons

🔗

Иконки и цвета можно брать свои, но в процессе первоначального знакомтсва вопользуемся тем, что предоставляет Radix UI

Terminal
pnpm add @radix-ui/colors @radix-ui/react-icons
 

Чтобы цвета применились их нужно прописать глобально

index.css
@import "@radix-ui/colors/black-alpha.css";
@import "@radix-ui/colors/green.css";
@import "@radix-ui/colors/mauve.css";
@import "@radix-ui/colors/violet.css";
 
:root {
  /* Accent Colors */
  --accent-100: #73a5ff;
  /*...*/

Отрисуем ModalRadix.tsx в Header.tsx рядом с компонентой Cart.tsx

Header.tsx
export const Header = () => {
  return (
    <div className={s.headerWrapper}>
      <div className={s.container}>
        <h3>logotype</h3>
        <Cart />
        <ModalRadix />
      </div>
    </div>
  )
}

demo radix modal

Результат. Демо модалка готова 🚀

Анатомия

Давайте разбираться как работать с Radix:

import * as Dialog from '@radix-ui/react-dialog'
 
export default () => (
  <Dialog.Root>
    <Dialog.Trigger />
    <Dialog.Portal>
      <Dialog.Overlay />
      <Dialog.Content>
        <Dialog.Title />
        <Dialog.Description />
        <Dialog.Close />
      </Dialog.Content>
    </Dialog.Portal>
  </Dialog.Root>
)
  • Читаем из каких составных частей Dialog:
  • Root - Содержит все части диалога
  • Trigger - Содержит все части диалога
  • Portal - При его использовании оверлей и части контента переносятся в document.body
  • Overlay - Слой, закрывающий инертную часть вида, когда диалог открыт
  • Content - Содержит содержимое, которое будет отображаться в открытом диалоге
  • Close - Кнопка, закрывающая диалог
  • Title - Доступный заголовок, который будет объявляться при открытии диалога
  • Description - Необязательное доступное описание, которое будет объявляться при открытии диалога

Такой подход соответствует compound паттерну

Delete post modal

Удалим все лишнее и реализуем модалку для удаления поста

ModalRadix.tsx
import s from './ModalRadix.module.css'
 
export const ModalRadix = () => (
  <Dialog.Root>
    <Dialog.Trigger asChild>
      <button className={`${s.Button} violet`}>Delete post</button>
    </Dialog.Trigger>
    <Dialog.Portal>
      <Dialog.Overlay className={s.Overlay} />
      <Dialog.Content className={s.Content}>
        <Dialog.Title className={s.Title}>Delete post</Dialog.Title>
        <hr />
        <Dialog.Description className={s.Description}>
          Are you sure you want to delete this post?
        </Dialog.Description>
        <Dialog.Close asChild>
          <button className={`${s.Button} violet`}>Yes</button>
        </Dialog.Close>
        <Dialog.Close asChild>
          <button className={`${s.Button} green`}>No</button>
        </Dialog.Close>
        <Dialog.Close asChild>
          <button className={s.IconButton} aria-label="Close">
            <Cross2Icon />
          </button>
        </Dialog.Close>
      </Dialog.Content>
    </Dialog.Portal>
  </Dialog.Root>
)

Из ModalRadix.module.css удалите стили: Fieldset, Label, Input

delete post modal

Результат. Модалка для удаления поста почти готова 🚀

Универсальная модалка

Мы опять пришли к тому, что модалка у нас не универсальная. Нужно стремиться к тому чтобы так написать модалку, чтобы ее можно было переиспользовать в любом приложении, тогда она действительно будет универсальная. При первоначальном знакомстве трудно сразу же продумать все нюасны, которые могу встретиться в жизни, но стараемся продумать насколько у нас получается.

Это не самая простая задача, но попробуем сделать первые шаги для создания универсальной модалки.

🔗

Ярким представителем, куда вы можете заходить, вдохновляться и смотреть исходный код является shadcn

Давайте сразу будем мыслить рамками нашего приложения. Если посмотреть на модалки в фигме, то у всех модалок есть заголовок с иконкой закрытия, разделитель и различный контент.

  • Trigger у каждой модалки свой, давайте его уберем из модалки вообще, а для управления состоянием будем передавать пропс состояния open и функцию закрытия модалки onClose

  • title у всех модалок разный, создадим для него тоже props modalTitle

  • контент у всех модалок тоже разный. Для отображения контента будем использовать children

  • кнопка закрытия есть у всех модалок в нашем дизайне. И как правило считается хорошей UX/UI практикой показывать кнопку закрытия, поэтому не будем здесь переусложнять

ModalRadix.tsx
type Props = {
  open: boolean
  onClose: () => void
  children: ReactNode
  modalTitle: string
}
 
export const ModalRadix = ({ modalTitle, onClose, children, open }: Props) => (
  <Dialog.Root open={open} onOpenChange={onClose}>
    <Dialog.Portal>
      <Dialog.Overlay className={s.Overlay} />
      <Dialog.Content className={s.Content}>
        <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>
)

Ну и на основании ModalRadix создадим новый компонент DeletePostModal

DeletePostModal.tsx
type Props = {
  open: boolean
  onClose: () => void
}
 
export const DeletePostModal = ({ open, onClose }: Props) => {
  return (
    <ModalRadix open={open} onClose={onClose} modalTitle={'Delete Post'}>
      Are you sure you want to delete this post?
      <div>
        {/*Будем использовать обычные кнопки*/}
        <button onClick={onClose}>Yes</button>
        <button onClick={onClose}>No</button>
      </div>
    </ModalRadix>
  )
}

Чтобы проверить работоспособность создадим компонент Posts, в котором и применим модалку

Posts.tsx
import { useState } from 'react'
import { DeletePostModal } from '../DeletePostModal/DeletePostModal.tsx'
import s from './Posts.module.css'
 
export const Posts = () => {
  const [posts, setPosts] = useState([
    { id: 1, title: 'Post 1' },
    { id: 2, title: 'Post 2' },
    { id: 3, title: 'Post 3' },
  ])
 
  const [showModal, setShowModal] = useState(false)
 
  const openModalHandler = () => {
    setShowModal(true)
  }
 
  const closeModalHandler = () => {
    setShowModal(false)
  }
 
  return (
    <>
      <div className={s.container}>
        <h2>Posts</h2>
        {posts.map(post => {
          return (
            <div className={s.postContainer} key={post.id}>
              <h3>{post.title}</h3>
              <button onClick={openModalHandler}>х</button>
            </div>
          )
        })}
      </div>
      <DeletePostModal open={showModal} onClose={closeModalHandler} />
    </>
  )
}
Posts.module.css
.container {
  margin: 100px 0 0 50px;
}
 
.postContainer {
  display: flex;
  align-items: center;
  gap: 10px;
}

Отрисуем Posts под Header

Posts.tsx
function App() {
  return (
    <>
      <Header />
      <Posts />
    </>
  )
}

delete post modal

Результат. При нажатии на иконку удаления модалка открывается с предупреждающим сообщением 🚀

Итоги

Теперь сравним 2 модалки

КомпонентModalModalRadix
Обработка клика за пределами модального окна
Закрытие по нажатию клавиши Escape
Поддержка порталов
Прием children для динамического содержимого
Доступность (a11y)
Стилизация и кастомизация❌✳️
правление фокусом
Блокировка прокрутки

✳️ - реализуем в следующем блоке