Skip to content

Instantly share code, notes, and snippets.

@denisso
Last active February 17, 2025 05:25
Show Gist options
  • Save denisso/8eed6cabca0dd90aa00fba798a42a3d3 to your computer and use it in GitHub Desktop.
Save denisso/8eed6cabca0dd90aa00fba798a42a3d3 to your computer and use it in GitHub Desktop.
Разработка интерактивных popup с автоматическим выравниванием и анимацией

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

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

Разработаем 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;
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment