Drawer (или шторка) — довольно популярный компонент в мобильном UI, и недавно понадобилось его реализовать в вебе (React, TypeScript, PostCSS). Так получилось, что в используемом нами UI-ките его не оказалось, и мне пришлось выйти в интернет с таким вопросом.
Делать велосипед я не хотел, потому что обкатанные решения учитывают какие-то нюансы, а я хотел быстрый результат с перспективой замены на более лучшее. Что я нашёл, и к чему это привело:
1. @yandex/ui
Оказалось, есть готовый компонент от Яндекс. Демо в сторибуке меня устроило: тач-действия, плавная анимация и пр.
Сначала меня смутило, что пришлось устанавливать много зависимостей, а раздувать бандл и дерево зависимостей не хотелось. Но ладно, для первой версии сойдёт.
Потом начались пляски со стилизацией, так как компонент не хотел подхватывать свои стили, и в текущую конфигурацию проекта плохо вписывался. Было бы проще, будь наш UI полностью на @yandex/ui. Тем временем затраты на настройку уже перестали укладываться в лимит. Поэтому решил вернуться к другим вариантам.
2. Material UI (MUI)
Этот вариант более монструозный, и тащить в проект ради одного простого компонента — это пушкой по воробьям.
К тому же, компонент не поддерживает тач-действия, которые нужны для закрытия шторки: https://mui.com/material-ui/react-drawer/
3. Другие UI-фреймворки
Отказался по тем же причинам.
4. Велосипед
Наконец, я понял, что проще создать свой велосипед. Плюсы: тесная интеграция с проектом, 0 зависимостей, 0 лишних движений для кастомизации. Минусы: возможно, местами поведение покажется деревянным, но я доволен решением, а причесать всегда можно.
Исходный код
import React, { useCallback, useEffect, useRef, useState } from 'react'
import classnames from 'classnames'
import style from './style.css'
type Props = {
children: React.ReactNode;
isClosable: boolean;
isOpen: boolean;
onClose?: () => void;
}
export const Drawer = ({
children,
isClosable,
isOpen,
onClose,
}: Props) => {
const drawerRef = useRef<HTMLDivElement>()
const overlayRef = useRef<HTMLDivElement>()
const [isPhysicallyOpen, setIsPhysicallyOpen] = useState(undefined)
const animationDuration = 300
const closeThreshold = 64
const isTouching = useRef(false)
const startPositionY = useRef(0)
const expandDrawer = useCallback(() => {
document.body.style.overflow = 'hidden'
if (drawerRef.current) {
drawerRef.current.classList.add(style.drawerExpanded)
}
if (overlayRef.current) {
overlayRef.current.classList.add(style.overlayExpanded)
}
}, [])
const collapseDrawer = useCallback(() => {
document.body.style.overflow = 'auto'
if (drawerRef.current) {
drawerRef.current.classList.remove(style.drawerExpanded)
drawerRef.current.style.transform = ''
drawerRef.current.style.bottom = '0'
}
if (overlayRef.current) {
overlayRef.current.classList.remove(style.overlayExpanded)
}
}, [])
const closeDrawer = useCallback(() => {
collapseDrawer()
setTimeout(() => {
setIsPhysicallyOpen(false)
}, animationDuration)
}, [collapseDrawer])
const handleOverlayClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (!isClosable) {
return
}
if (event.target.getAttribute('data-testid') === 'overlay') {
closeDrawer()
}
}, [closeDrawer, isClosable])
const initEvents = () => {
if (!drawerRef.current) {
console.log('Drawer node not exists')
return
}
if (isClosable) {
drawerRef.current.addEventListener('touchstart', handleTouchStart)
drawerRef.current.addEventListener('touchmove', handleTouchMove)
drawerRef.current.addEventListener('touchend', handleTouchEnd)
}
}
useEffect(() => {
if (isOpen) {
setIsPhysicallyOpen(true)
} else if (typeof isPhysicallyOpen === 'boolean') {
closeDrawer()
}
}, [isOpen])
useEffect(() => {
if (isPhysicallyOpen) {
setTimeout(() => {
expandDrawer()
}, 100)
initEvents()
} else if (onClose && typeof isPhysicallyOpen === 'boolean') {
onClose()
}
}, [isPhysicallyOpen])
useEffect(() => () => {
collapseDrawer()
}, [])
const handleDrawerClick = useCallback((event: React.MouseEvent) => {
if (!(event.target === drawerRef.current || drawerRef.current.contains(event.target)) && drawerOpen.current) {
event.preventDefault()
closeDrawer()
}
}, [closeDrawer])
const handleTouchStart = useCallback((event: TouchEvent) => {
const currentTouch = event.targetTouches[0]
startPositionY.current = currentTouch.pageY
isTouching.current = true
}, [])
const handleTouchMove = useCallback((event: TouchEvent) => {
const touch = event.targetTouches[0]
const diff = touch.pageY - startPositionY.current
if (isTouching.current && touch.pageY > startPositionY.current) {
drawerRef.current.style.bottom = `-${diff}px`
}
}, [])
const handleTouchEnd = useCallback((event: TouchEvent) => {
if (!isTouching.current) {
return
}
const currentTouch = event.changedTouches[0]
const diff = currentTouch.pageY - startPositionY.current
if (isTouching.current && diff > closeThreshold) {
collapseDrawer()
} else {
drawerRef.current.style.bottom = '0'
}
isTouching.current = false
}, [])
if (!isPhysicallyOpen) {
return null
}
return (
<div
className={style.overlay}
data-testid="overlay"
ref={overlayRef}
onClick={handleOverlayClick}
>
<div
className={classnames(style.drawer)}
ref={drawerRef}
onClick={handleDrawerClick}
>
{isClosable && <div className={style.line} />}
<div className={style.inner}>
{children}
</div>
</div>
</div>
)
}
.drawer {
background-color: #fff;
border-radius: 8x 8x 0 0;
bottom: 0;
left: 0;
overflow: hidden;
padding: 8px 0 0;
position: fixed;
transform: translate(0, 100%);
transition: transform .3s linear, bottom .3s linear;
width: 100vw;
z-index: 1100;
}
.inner {
display: flex;
flex-direction: column;
margin: 0 auto;
padding: 24px 16px;
}
.drawer-expanded {
transform: translate(0, 0);
}
.line {
background-color: #999;
border-radius: 2px;
height: 4px;
margin: 0 auto;
width: 32px;
}
.overlay {
background-color: rgb(0 0 0 / 0%);
display: block;
inset: 0;
position: fixed;
transition: background-color .3s linear;
z-index: 6;
}
.overlay-expanded {
background-color: rgb(0 0 0 / 55%);
}
Как использовать
<Drawer
isClosable
isOpen={isOpened}
onClose={onClose}
>
Содержимое
</Drawer>