Форум программистов, компьютерный форум, киберфорум
mr_dramm
Войти
Регистрация
Восстановить пароль
Блоги Сообщество Поиск Заказать работу  

Интерактивные popup с автоматическим выравниванием и анимацией

Запись от mr_dramm размещена 09.02.2025 в 23:00
Показов 1480 Комментарии 0

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

1. Анимация в браузерах и как с ней работать Сергей Чикуёнок (VK)

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

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

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

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

Базовый пример
Демо
Код
Кликните здесь для просмотра всего текста

HTML5
1
2
    <div id="app"></div>
    <button id="btnCreatePopup">Add popup</button>
CSS
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: fixed;
  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;
}
JavaScript
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
134
135
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) {
        if (node.value.animation.playState == 'finished') continue;
        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>
  <button class="close">X</button>
  `;
 
  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);
    node.value.animation = animation;
    animations.add(animation);
    animation.addEventListener('finish', animationEndHandler);
    popup.style.transform = `translateX(0)`;
    verticalAlignPopups();
  });
};
 
const btnCreatePopup = document.getElementById('btnCreatePopup');
btnCreatePopup.addEventListener('click', createPopup);


Пример с использованием template
Демо
Код
Кликните здесь для просмотра всего текста

HTML5
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>
        <button class="close">X</button>
      </div>
    </template>
    <div id="app"></div>
    <button id="btnCreatePopup">Add popup</button>
Изменения относительно базового примера только в функции createPopup, клонируем содержимое template в DOM с помощью popupTemplate.content.cloneNode(true)

было
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  const popupContent = `
  <div class="content">
  ${texts[textIndx++]}
  </div>
  <div class="line-box">
    <div class="line"></div>
  </div>
  <button class="close">X</button>
  `;
 
  let popup = document.createElement('div');
 
  popup.classList.add('popup');
  popup.insertAdjacentHTML('afterbegin', popupContent);
стало
JavaScript
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 инкапсулирует свою разметку и стили, поэтому стили, определённые в <head>, не применяются к элементам внутри теневого дерева. Один из вариантов устанавливать стили для Web components это добавить их в template и при создании компонента записать содержимое template в Shadow DOM.
HTML5
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
<!DOCTYPE html>
    <template id="popup-template">
      <style>
        .popup {
          position: fixed;
          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>
        <button class="close">X</button>
      </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)
JavaScript
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
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);
    this.node.value.animation = this.animation;
    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
Кликните здесь для просмотра всего текста
JavaScript
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;
  }
}
Размещено в ui, web animations api, html, javascript, animation
Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
Модель здравосохранения 14. Собираем всю модель вместе.
anaschu 22.05.2026
Модель собрана. В будущих постах на видео я покажу, как она работает. В этом посте запускаем её, проверяем результаты и разбираем что можно с ней делать дальше. Перед запуском проверяем. . .
Модель здравоохранения 13. Добавление самой системы здравоохранения.
anaschu 22.05.2026
В предыдущем посте мы настроили болезни. Теперь добавим события, которые управляют здоровьем всего коллектива, а также настроим рабочий график и расчёт финансов. В Main создаём четыре события. . . .
Модель здравоохранения 12. добавление болезней через ресурпул, как аварии
anaschu 22.05.2026
Болезни — это ключевая часть нашей модели. Нам нужно, чтобы работник периодически уходил на больничный, его задание при этом зависало, а после выздоровления работа возобновлялась. Реализуем это двумя. . .
Модель здравоохранения 11. Создаём классы Задание и Работник
anaschu 22.05.2026
В AnyLogic каждая заявка и каждый ресурс — это объект определённого класса. Нам нужно создать два класса: Задание (заявка) и Работник (ресурс). Класс Задание В дереве проекта нажимаем правой. . .
Модель здравоохранения 10. Новая модель, смотрим, как добавлять логические блоки, и что писать внутри
anaschu 22.05.2026
Открываем AnyLogic, создаём новый проект. В дереве проекта появляется класс Main — это главный агент, в котором будет жить вся наша логика. Палитра блоков Слева находится палитра. Нас интересует. . .
модель ЗдравоСохранения 9. Новая модель, разбираемся, как ее создавать
anaschu 22.05.2026
В этой серии постов мы построим модель небольшого рабочего коллектива. Сотрудники получают задания, выполняют их, иногда болеют — и мы хотим посчитать, сколько это стоит компании. Метод. . .
[golang] Linked list
alhaos 22.05.2026
Связный список / Linked list Связный список структура данных позволяющая хранить список значений, в отличии от массива в памяти хранится не сплошным куском, а отдельными частями которые ссылаются. . .
[golang] Двоичная куча, min-heap
alhaos 20.05.2026
Двоичная куча Двоичная куча — структура данных, которая всегда держит самый важный элемент наготове. Представьте очередь к хилеру в игре, и очередь из игроков в приоритете те у кого меньше. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2026, CyberForum.ru