За обновлениями можно следить в telegram-канале https://t.me/quasiart

Drawer (или шторка) — довольно популярный компонент в мобильном UI, и недавно понадобилось его реализовать в вебе (React, TypeScript, PostCSS). Так получилось, что в используемом нами UI-ките его не оказалось, и мне пришлось выйти в интернет с таким вопросом.

Делать велосипед я не хотел, потому что обкатанные решения учитывают какие-то нюансы, а я хотел быстрый результат с перспективой замены на более лучшее. Что я нашёл, и к чему это привело:

1. @yandex/ui

Оказалось, есть готовый компонент от Яндекс. Демо в сторибуке меня устроило: тач-действия, плавная анимация и пр.

https://yastatic.net/s3/frontend/lego/storybook/index.html?path=/story/surface-drawer-touch-phone--playground

Сначала меня смутило, что пришлось устанавливать много зависимостей, а раздувать бандл и дерево зависимостей не хотелось. Но ладно, для первой версии сойдёт.

Потом начались пляски со стилизацией, так как компонент не хотел подхватывать свои стили, и в текущую конфигурацию проекта плохо вписывался. Было бы проще, будь наш 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 './Drawer.css'

type Props = {
    children: React.ReactNode;
    isClosable: boolean;
    isOpen: boolean;
    onClose: () => void;
}

export const Drawer = ({
    children,
    isClosable,
    isOpen,
    onClose,
}: Props) => {
    const closeThreshold = 64 // На сколько пикселей нужно свайпнуть для закрытия

    const drawerRef = useRef<HTMLDivElement>()
    const overlayRef = useRef<HTMLDivElement>()
    const [isPhysicallyOpen, setIsPhysicallyOpen] = useState(false)
    const drawerHeightRef = useRef(0)
    const isSwiping = 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)
        }, 300)
    }, [collapseDrawer])

    const handleOverlayClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
        if (!isClosable) {
            return
        }

        if (event.target.getAttribute('data-testid') === 'overlay') {
            closeDrawer()
        }
    }, [closeDrawer, isClosable])

    useEffect(() => {
        if (isOpen) {
            setIsPhysicallyOpen(true)
        } else {
            closeDrawer()
        }
    }, [isOpen])

    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 (isPhysicallyOpen) {
            setTimeout(() => {
                expandDrawer()
            }, 100)
            initEvents()
        } else {
            onClose()
        }
    }, [isPhysicallyOpen])

    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
        drawerHeightRef.current = drawerRef.current.offsetHeight
        isSwiping.current = true
    }, [])

    const handleTouchMove = useCallback((event: TouchEvent) => {
        const touch = event.targetTouches[0]
        const diff = touch.pageY - startPositionY.current
        if (isSwiping.current && touch.pageY > startPositionY.current) {
            drawerRef.current.style.bottom = `-${diff}px`
        }
    }, [])

    const handleTouchEnd = useCallback((event: TouchEvent) => {
        if (!isSwiping.current) {
            return
        }
        const currentTouch = event.changedTouches[0]
        const diff = currentTouch.pageY - startPositionY.current

        if (isSwiping.current && diff > closeThreshold) {
            collapseDrawer()
        } else {
            drawerRef.current.style.bottom = '0'
        }

        isSwiping.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%);
}