✍️ Множество popup c авто выравниванием
Рекомендуется к прочтению:
- Анимация в браузерах и как с ней работать секция "Неочевидные моменты в работе Layout/reflow"
Постановка задачи
Разработаем popup с автоматическим вертикальнием выравнивание при добавлении и удалении элементов, а также автоматическое удаление popup через заданный интервал времени. При добавлении и удалении используем анимацию для улучшения пользовательского опыта
Функциональные фичи:
- автоматическое вертикальное выравнивание при добавлении и удалении popup
- автоматическое удаление popup через указанные промежуток времени
- анимация появления удаления выравнивания
- остановка анимации и воспроизведение при наведении курсора на popup
Кодовые Фичи:
- использование структуры List для хранения popup, позволяет меньше вызывать querySelectorAll
- минимизируем количество reflow, разделяя получение размеров (getBoundingClientRect()) и установку transform, подробнее в статье 1 раздел "Неочевидные моменты в работе Layout/reflow"
- запуск перерисовки с requestAnimationFrame в createPopup и для оптимизации отрисовки за один кадр в verticalAlignPopups
- использование transform вместо position дает преимущества, подробнее в статье 1 раздел "Рендеринг и анимация в отдельном потоке"
Базовый пример
Код
<div id="app"></div>
<button id="btnCreatePopup">Add popup</button>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
overflow-x: hidden;
}
.popup {
position: absolute;
display: flex;
max-width: 400px;
border: solid;
right: 0;
bottom: 0;
transform: translateX(100%);
transition: transform 0.2s;
background-color: white;
}
.content {
position: relative;
flex: 1;
padding: 1rem;
}
.line-box {
position: absolute;
bottom: 0;
left: 0;
height: 5px;
display: flex;
width: 100%;
overflow: hidden;
}
.line {
background-color: red;
flex: 1;
}
.close {
cursor: pointer;
user-select: none;
position: absolute;
top: 0;
right: 0;
}
import List from './list.js';
import throttle from './throttle';
// instead of a List, you can use querySelectorAll()
const list = new List();
const duration = 5000;
const texts = [
'Lorem, ipsum dolor sit amet consectetur, adipisicing elit.',
'Voluptatum mollitia dicta ab dolorem iure similique fugiat sapiente ullam dignissimos maxime quo alias ea quasi magni magnam facilis aspernatur, asperiores temporibus. Provident quis totam, maiores recusandae ut expedita eligendi dolor sed, tempora, quo asperiores nobis, vitae error? Suscipit nihil nesciunt aliquam in, enim!',
];
let textIndx = 0;
const verticalGap = 16;
const verticalAlignPopups = () => {
if (!list.tail) return;
requestAnimationFrame(() => {
// First rAF: calculate tY 1 reflow
list.tail.value.tY = 0;
for (
let node = list.tail.prev,
prevHeight = list.tail.value.popup.getBoundingClientRect().height;
node;
node = node.prev
) {
node.value.tY = -(
Math.abs(node.next.value.tY) +
prevHeight +
verticalGap
);
prevHeight = node.value.popup.getBoundingClientRect().height;
}
// Second rAF: for paint и composite
requestAnimationFrame(() => {
for (let node = list.tail; node; node = node.prev) {
node.value.popup.style.transform = `translate(0, ${node.value.tY}px)`;
}
});
});
};
window.addEventListener(
'resize',
throttle(() => verticalAlignPopups())
);
const moveRight = [
[{ transform: 'translateX(0)' }, { transform: 'translateX(100%)' }],
{
id: 'moveRight',
duration,
easing: 'linear',
fill: 'forwards',
},
];
const animations = new Set();
const createPopup = () => {
if (textIndx == texts.length) textIndx = 0;
const popupContent = `
<div class="content">
${texts[textIndx++]}
</div>
<div class="line-box">
<div class="line"></div>
</div>
<div class="close">X</div>
`;
let popup = document.createElement('div');
popup.classList.add('popup');
popup.insertAdjacentHTML('afterbegin', popupContent);
let finished = false;
// p = true - play otherwise pause
const ppAnimations = (p) => {
if (finished) return;
animations.forEach((a) => {
if (a.playState == 'finished') return;
p ? a.play() : a.pause();
});
};
const mouseenterListener = () => ppAnimations(false),
mouseleaveListener = () => ppAnimations(true);
let node = list.append({ popup, tY: 0 });
popup.addEventListener('mouseenter', mouseenterListener);
popup.addEventListener('mouseleave', mouseleaveListener);
let animation;
const animationEndHandler = () => {
popup.addEventListener('transitionend', cleanAndAlign);
popup.style.transform = `translate(100%, ${node.value.tY}px)`;
list.remove(node);
node = null;
};
const clickHandler = () => {
ppAnimations(true);
finished = true;
animationEndHandler();
};
const cleanAndAlign = () => {
popup.removeEventListener('mouseenter', mouseenterListener);
popup.removeEventListener('mouseleave', mouseleaveListener);
popup.removeEventListener('transitionend', cleanAndAlign);
popup.querySelector('.close').removeEventListener('click', clickHandler);
animation.removeEventListener('finish', animationEndHandler);
if (animation.playState != 'finished') animation.cancel();
animations.delete(animation);
animation = null;
popup.remove();
popup = null;
verticalAlignPopups();
};
popup.querySelector('.close').addEventListener('click', clickHandler);
document.body.appendChild(popup);
requestAnimationFrame(() => {
animation = popup.querySelector('.line').animate(...moveRight);
animations.add(animation);
animation.addEventListener('finish', animationEndHandler);
popup.style.transform = `translateX(0)`;
verticalAlignPopups();
});
};
const btnCreatePopup = document.getElementById('btnCreatePopup');
btnCreatePopup.addEventListener('click', createPopup);
Пример с использованием template
<template id="popup-template">
<div class="popup">
<div class="content"></div>
<div class="line-box">
<div class="line"></div>
</div>
<div class="close">X</div>
</div>
</template>
<div id="app"></div>
<button id="btnCreatePopup">Add popup</button>
Изменения относительно базового примера только в функции createPopup, клонируем содержимое template в DOM с помощью popupTemplate.content.cloneNode(true)
const popupTemplate = document.getElementById('popup-template');
...
let popup = popupTemplate.content.cloneNode(true).querySelector('.popup');
popup.querySelector('.content').innerHTML = texts[textIndx++];
Пример с использованием web components
Shadow DOM инкапсулирует свою разметку и стили, поэтому стили, определённые в , не применяются к элементам внутри теневого дерева. Один из вариантов устанавливать стили для Web components это добавить их в template и при создании компонента записать содержимое template в Shadow DOM.
<template id="popup-template">
<style>
.popup {
position: absolute;
display: flex;
max-width: 400px;
border: solid;
right: 0;
bottom: 0;
transform: translateX(100%);
transition: transform 0.2s;
background-color: white;
}
.content {
position: relative;
flex: 1;
padding: 1rem;
}
.line-box {
position: absolute;
bottom: 0;
left: 0;
height: 5px;
display: flex;
width: 100%;
overflow: hidden;
}
.line {
background-color: red;
flex: 1;
}
.close {
cursor: pointer;
user-select: none;
position: absolute;
top: 0;
right: 0;
}
</style>
<div class="popup">
<div class="content"></div>
<div class="line-box">
<div class="line"></div>
</div>
<div class="close">X</div>
</div>
</template>
<div id="app"></div>
<button id="btnCreatePopup">Add popup</button>
изменения относительно базового примера
- Создаем класс, который наследуется от HTMLElement, в котором будет инициализация shadowDom и логика управления компонентом
- Регистрация компонента customElements.define
- Добавление компонента в DOM document.createElement('popup-element') и document.body.appendChild(popup)
class Popup extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const fragment = popupTemplate.content.cloneNode(true);
this.shadowRoot.appendChild(fragment);
this.popup = this.shadowRoot.querySelector('.popup');
this.node = list.append({ popup: this.popup, tY: 0 });
this.onMouseEnter = () => this.ppAnimations(false);
this.onMouseLeave = () => this.ppAnimations(true);
this.onClick = () => {
this.ppAnimations(true);
this.finished = true;
this.animationEndHandler();
};
this.onTransitionEnd = this.cleanAndAlign.bind(this);
this.addEventListener('mouseenter', this.onMouseEnter);
this.addEventListener('mouseleave', this.onMouseLeave);
this.finished = false;
this.shadowRoot
.querySelector('.close')
.addEventListener('click', this.onClick);
}
// p = true - play otherwise pause
ppAnimations(p) {
if (this.finished) return;
animations.forEach((a) => {
if (a.playState == 'finised') return;
p ? a.play() : a.pause();
});
}
cleanAndAlign() {
this.removeEventListener('mouseenter', this.onMouseEnter);
this.removeEventListener('mouseleave', this.onMouseLeave);
this.popup.removeEventListener('transitionend', this.onTransitionEnd);
this.animation.cancel();
animations.delete(this.animation);
this.animation = null;
this.remove();
verticalAlignPopups();
}
animationEndHandler() {
this.popup.addEventListener('transitionend', this.onTransitionEnd);
this.shadowRoot
.querySelector('.close')
.removeEventListener('click', this.onClick);
this.popup.style.transform = `translate(100%, ${this.node.value.tY}px)`;
list.remove(this.node);
this.node = null;
}
show() {
this.animation = this.shadowRoot
.querySelector('.line')
.animate(...moveRight);
animations.add(this.animation);
this.animation.addEventListener('finish', (e) => {
if (e.target.id !== 'moveRight') return;
this.animationEndHandler();
});
this.popup.style.transform = `translateX(0)`;
verticalAlignPopups();
}
}
customElements.define('popup-element', Popup);
const createPopup = () => {
const popup = document.createElement('popup-element');
if (textIndx == texts.length) textIndx = 0;
popup.shadowRoot.querySelector('.content').innerHTML = texts[textIndx++];
document.body.appendChild(popup);
requestAnimationFrame(() => {
popup.show();
});
};
Служебный класс List
class Node {
constructor(value) {
this.value = value;
this.next = this.prev = null;
}
}
export default class List {
constructor() {
this.tail = null;
this.head = null;
}
append(value) {
const tail = new Node(value);
if (this.tail) {
tail.prev = this.tail;
this.tail.next = tail;
}
this.tail = tail;
return tail;
}
remove(node) {
if (node === this.head) {
this.head = node.next;
if (this.head) {
this.head.prev = null;
} else {
this.tail = null;
}
} else if (node === this.tail) {
this.tail = node.prev;
if (this.tail) {
this.tail.next = null;
} else {
this.head = null;
}
} else {
if (node.prev) {
node.prev.next = node.next;
}
if (node.next) {
node.next.prev = node.prev;
}
}
node.value = null;
node.next = null;
node.prev = null;
}
}