✍️ Множество popup c авто выравниванием

Рекомендуется к прочтению:

Постановка задачи

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

Функциональные фичи:

  • автоматическое вертикальное выравнивание при добавлении и удалении popup
  • автоматическое удаление popup через указанные промежуток времени
  • анимация появления удаления выравнивания
  • остановка анимации и воспроизведение при наведении курсора на popup

Кодовые Фичи:

  • использование структуры List для хранения popup, позволяет меньше вызывать querySelectorAll
  • минимизируем количество reflow, разделяя получение размеров (getBoundingClientRect()) и установку transform, подробнее в статье 1 раздел "Неочевидные моменты в работе Layout/reflow"
  • запуск перерисовки с requestAnimationFrame в createPopup и для оптимизации отрисовки за один кадр в verticalAlignPopups
  • использование transform вместо position дает преимущества, подробнее в статье 1 раздел "Рендеринг и анимация в отдельном потоке"

Базовый пример

Демо

Код

1
2
    <div id="app"></div>
    <button id="btnCreatePopup">Add popup</button>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
* {
  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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
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

Демо

1
2
3
4
5
6
7
8
9
10
11
    <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)

1
2
3
4
  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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
    <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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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;
  }
}
Автор сайта Денис aka mr_dramm