Привет, Хабр!
Буквально недавно на работе я получил баг с z-index
, я его по быстрому пофиксил и получил еще два бага. Я как то не придавал этой проблеме значения, и тут мой коллега Дмитрий Рокало ревьювил мой очередной пул реквест и пришел ко мне с идеей, как покончить войну с z-index
в нашем проекте. И как раз в тот же день, я слушал подкаст веб стандарты и там обсуждали статью по работе с z-index
. И решение, которое предлагают в статье, показалось мне достаточно сложным по сравнению с тем, что предложил мне Дима. Поэтому я решил спонтанно записать это видео и написать статью. Возможно это решение кому-то будет полезным (Данная статья является расшифровкой видео).
Обрисуем ситуацию
Давайте рассмотрим пример. У нас при клике на иконку открывается попап или модальное окно или назовите его еще как угодно. Сейчас речь не про нейминг, но мы в проекте у себя называем это попапом. Этот попап всегда находится над основным контентом, поэтому мы дали всем попапам z-index: 100
, и это сработало.
.popup {
z-index: 100;
}
.popover {
z-index: 10000;
}
В некоторых проектах, чтобы управлять z-index
в одном месте, мы создавали отдельный файл с scss переменными.
z-index-popup: 100;
z-index-popover: 1000;
Баги, конечно, возникали, но их было фиксить достаточно просто, когда в одном файле видишь всю картину проекта.
Последствия такого подхода
И спустя какое-то время у нас на проекте появился новый поповер. Когда нажимаешь на аватарку другого пользователя, открывается поповер с более подробной информацией о нем и возможностью заблокировать этого пользователя.
Если мы нажимаем на кнопку заблокировать пользователя, тогда появляется дополнительный попап, который спрашивает: "а вы уверены, что хотите заблокировать пользователя?"
И тут появился тот самый баг, который вы видите на экране. z-index
попала 100, а z-index
поповера 1000. Конечно же я сразу подшаманил, чтобы все работало, но это походило скорее на костыль.
Решение
В основе нашего решения лежит использование порталов. Давайте вспомним, что это такое:
Так сложилось, что когда мы создавали свой UiKit мы решили, что попап и поповер мы будем вставлять в проект через портал. Это было сделано для того, чтобы случайно какой-нибудь overflow: hidden
не обрезал какую-либо важную часть. Я думаю многие сталкивались с этой проблемой.
Компонент <Portal>
Сам компонент <Portal>
выглядит следующим образом. При первом рендере мы создаем <div>
и храним его в state
. Далее с помощью createPortal
мы кладем children
внутрь только что созданного <div>
. И в useEffect
все тот же <div>
уже начиненный каким-то контентом помещаем в конец body
. И при анмаунте компонента все тот же <div>
удаляется из body
. Компонент достаточно простой.
import { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
const Portal = ({ children }) => {
const [container] = useState(() => document.createElement('div'));
useEffect(() => {
document.body.appendChild(container);
return () => {
document.body.removeChild(container);
};
}, []);
return ReactDOM.createPortal(children, container);
};
export default Portal;
Один из ключевых моментов здесь, это то что мы помещаем каждый новый портал именно в конец какого-то контейнера в нашем случае это body
.
Суть этой особенности, если вставить несколько <div>
подряд с одинаковым z-index
. В таком случае <div>
, который является последним всегда будет поверх предыдущих.
Компонент <Popup>
Остается только использовать этот компонент <Portal>
в UiKit компоненте <Popup>
. Здесь мы оборачиваем весь контент компонентом <Portal>
. А внутри вставляем <div>
. У которого position: fixed
на весь экран и z-index: 1
.
const Popup = ({ children, onClose, isOpened }) => {
if (!isOpened) {
return null;
}
return (
<Portal>
<div className="popup" role="dialog">
<div
className="overlay"
role="button"
tabIndex={0}
onClick={onClose}
/>
<div className="content">{children}</div>
</div>
</Portal>
);
};
Компонент <Popover>
Точно тоже самое мы делаем с компонентом <Popover>
. Точно так же оборачиваем весь контент в <Portal>
, далее оборачиваем в обработчик <ClickOutside>
для обработки клика вне поповера и уже идет сам контейнер <Popper>
от библиотеки react-popper
. Который навешивает инлайн стилями position: absolute
на наш <div>
, и остается добавить ему только z-index: 1
.
const Popover = ({ onClose, reference, placement, children }) => {
const popperRef = useRef();
return (
<Portal>
<ClickOutside reference={popperRef.current} onClickOutside={onClose}>
<Popper
innerRef={popperRef}
referenceElement={reference}
placement={placement}
>
{({ ref, style }) => (
<div ref={ref} style={style} className="popover">
{children}
</div>
)}
</Popper>
</ClickOutside>
</Portal>
);
};
Вот и вся реализация
Проверим результат
Пример 1
Перейдем к первому примеру с иконкой. Мы нажимаем на иконку - открывается попап. Он как мы знаем добавился в конец body
и имеет z-index: 1
, поэтому показывается поверх остального контента. Далее мы открываем меню пользователя, которое отображается в поповере и он точно так же, как и попап добавляется в конец body
и т.к. позиция в DOM дереве у поповера ниже, поэтому он показывается поверх попапа.
Пример 2
С другой стороны, рассмотрим снова пример с аватаркой. Кликнем по аватарке, появится поповер и в конец body
он так же добавился. Нажимаем кнопку заблокировать пользователя и видим попап уже не под поповером, а над поповером. Это произошло, потому что теперь попап в конце DOM дерева и поэтому у него позиция выше. И такой фокус работает при любом количестве разных типов компонентов вставляемых через <Portal>
.
Подытожим
Суть данного подхода очень простая: какой элемент последний появился на экране, тот и показывается поверх всего. А если вам вдруг в каком то кейсе такая логика не подходит. Вы можете просто присвоить z-index: 2
. Хотя я сомневаюсь, что вам это понадобится. По крайней мере в нашем достаточно сложном проекте с 30+ попапов и столько же поповеров, вроде бы закрыло все кейсы. По крайней мере, пока никто не жалуется).
Bone
Очень интересное решение и красивое решение.
Sin9k Автор
Да, мне тоже оно настолько понравилось, что решил опубликовать, мало ли кому еще пригодится)