Другое

Vue.js модальное окно: позиционирование при масштабировании

Узнайте, как создать модальное окно в Vue.js, которое сохраняет позицию относительно кнопки при изменении масштаба страницы. Решение проблемы с getBoundingClientRect().

Как создать модальное окно в Vue.js, которое всегда позиционируется рядом с кнопкой‑триггером без перекрытия и сохраняет позицию при изменении масштаба страницы?

Мне нужно реализовать модальное окно на Vue, которое:

  • При нажатии на кнопку всегда отображается рядом с ней (не перекрывая кнопку).
  • Сохраняет свою позицию относительно кнопки при изменении масштаба страницы.
  • Не «убегает» от кнопки при масштабировании, как это происходит при использовании getBoundingClientRect().

Пример желаемого поведения можно увидеть на сайте vc.ru при нажатии на кнопку «Поделиться» под статьями.

Я пробовал использовать getBoundingClientRect(), но модальное окно перемещается по странице при изменении масштаба.

Vue.js модальное окно можно создать с использованием CSS‑позиционирования на основе viewport‑относительных единиц измерения или CSS Grid/Flexbox вместо абсолютных пиксельных координат из getBoundingClientRect(). Это решит проблему смещения при масштабировании страницы.

Содержание


Проблема с использованием getBoundingClientRect()

При использовании getBoundingClientRect() для позиционирования модального окна возникает фундаментальная проблема: этот метод возвращает абсолютные пиксельные координаты относительно viewport. При изменении масштаба страницы (через Ctrl+ или Ctrl-) браузер масштабирует содержимое, но абсолютные пиксельные значения остаются неизменными, что приводит к смещению модального окна относительно кнопки‑триггера.

javascript
// Проблемный код, который "убегает" при масштабировании
const buttonRect = button.getBoundingClientRect();
modal.style.left = `${buttonRect.right + 10}px`;
modal.style.top = `${buttonRect.top}px`;

Решение с CSS viewport‑единицами

Более надёжный подход — использование viewport‑относительных единиц измерения (vw, vh, vmin, vmax) или CSS‑свойств, которые автоматически адаптируются к масштабу.

Основные принципы:

  1. Использование position: fixed с viewport‑координатами
  2. Калькуляция позиций в процентах от viewport
  3. CSS‑переменные для динамического позиционирования
vue
<template>
  <div class="trigger-button" @click="showModal = true">
    Открыть модальное окно
  </div>
  
  <div v-if="showModal" 
       class="modal"
       :style="modalStyle">
    <div class="modal-content">
      Содержимое модального окна
      <button @click="showModal = false">Закрыть</button>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      showModal: false,
      modalStyle: {}
    }
  },
  methods: {
    positionModal(buttonElement) {
      const buttonRect = buttonElement.getBoundingClientRect();
      const modal = this.$el.querySelector('.modal');
      
      // Используем viewport‑относительные расчёты
      const viewportWidth = window.innerWidth;
      const viewportHeight = window.innerHeight;
      
      // Позиционируем относительно viewport, а не абсолютные пиксели
      const leftPercent = (buttonRect.right / viewportWidth) * 100;
      const topPercent = (buttonRect.top / viewportHeight) * 100;
      
      this.modalStyle = {
        position: 'fixed',
        left: `${leftPercent}%`,
        top: `${topPercent}%`,
        transform: 'translate(-100%, 0)', // Смещаем влево от кнопки
      };
    }
  }
}
</script>

<style>
.modal {
  position: fixed;
  z-index: 1000;
  background: white;
  border: 1px solid #ccc;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}

.modal-content {
  padding: 20px;
  min-width: 300px;
}
</style>

Использование CSS Grid для позиционирования

Современный подход — использование CSS Grid для создания контейнера, который автоматически адаптируется к масштабу.

vue
<template>
  <div class="modal-container">
    <button class="trigger-button" @click="showModal = true">
      Открыть
    </button>
    
    <transition name="fade">
      <div v-if="showModal" class="modal-wrapper">
        <div class="modal" ref="modal">
          <div class="modal-content">
            Содержимое модального окна
            <button @click="showModal = false">Закрыть</button>
          </div>
        </div>
      </div>
    </transition>
  </div>
</template>

<script>
export default {
  data() {
    return {
      showModal: false
    }
  },
  watch: {
    showModal(newVal) {
      if (newVal) {
        this.$nextTick(() => {
          this.positionModalWithGrid();
        });
      }
    }
  },
  methods: {
    positionModalWithGrid() {
      const button = this.$el.querySelector('.trigger-button');
      const modal = this.$refs.modal;
      
      if (!button || !modal) return;
      
      const buttonRect = button.getBoundingClientRect();
      
      // Создаём временный grid для позиционирования
      document.body.style.display = 'grid';
      document.body.style.gridTemplateColumns = '1fr';
      document.body.style.gridTemplateRows = '1fr';
      
      // Позиционируем модальное окно рядом с кнопкой
      modal.style.gridRow = Math.ceil(buttonRect.top / window.innerHeight) + ' / ' + 
                           Math.ceil((buttonRect.bottom) / window.innerHeight);
      modal.style.gridColumn = Math.ceil(buttonRect.right / window.innerWidth) + ' / auto';
      
      // Возвращаем нормальный layout после анимации
      setTimeout(() => {
        document.body.style.display = '';
        document.body.style.gridTemplateColumns = '';
        document.body.style.gridTemplateRows = '';
      }, 300);
    }
  }
}
</script>

<style>
.modal-container {
  position: relative;
}

.modal-wrapper {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  background: rgba(0,0,0,0.5);
  z-index: 1000;
}

.modal {
  background: white;
  border-radius: 8px;
  box-shadow: 0 4px 20px rgba(0,0,0,0.15);
  max-width: 400px;
  width: 90%;
}

.fade-enter-active, .fade-leave-active {
  transition: opacity 0.3s;
}
.fade-enter, .fade-leave-to {
  opacity: 0;
}
</style>

Vue.js компонент с реактивным позиционированием

Вот полноценный Vue.js компонент, который решает проблему масштабирования:

vue
<template>
  <div class="relative-position">
    <button 
      ref="triggerButton"
      class="trigger-button"
      @click="toggleModal"
    >
      {{ buttonText }}
    </button>
    
    <transition name="slide">
      <div v-if="isVisible" 
           class="modal-overlay"
           @click="closeOnOverlayClick && closeModal()">
        <div 
          ref="modal"
          class="modal"
          :style="modalPositionStyle"
          @click.stop
        >
          <div class="modal-header">
            <h3>{{ title }}</h3>
            <button class="close-button" @click="closeModal">×</button>
          </div>
          <div class="modal-body">
            <slot></slot>
          </div>
        </div>
      </div>
    </transition>
  </div>
</template>

<script>
export default {
  name: 'PositionedModal',
  props: {
    buttonText: {
      type: String,
      default: 'Открыть'
    },
    title: {
      type: String,
      default: 'Модальное окно'
    },
    position: {
      type: String,
      default: 'right', // 'right', 'left', 'top', 'bottom'
      validator: (value) => ['right', 'left', 'top', 'bottom'].includes(value)
    },
    offset: {
      type: Number,
      default: 10
    },
    closeOnOverlayClick: {
      type: Boolean,
      default: true
    }
  },
  data() {
    return {
      isVisible: false,
      modalPositionStyle: {}
    }
  },
  methods: {
    toggleModal() {
      this.isVisible = !this.isVisible;
      if (this.isVisible) {
        this.$nextTick(() => {
          this.calculatePosition();
        });
      }
    },
    closeModal() {
      this.isVisible = false;
    },
    calculatePosition() {
      const button = this.$refs.triggerButton;
      const modal = this.$refs.modal;
      
      if (!button || !modal) return;
      
      const buttonRect = button.getBoundingClientRect();
      const viewportWidth = window.innerWidth;
      const viewportHeight = window.innerHeight;
      
      // Рассчитываем позиции в viewport‑процентах
      let left, top;
      
      switch (this.position) {
        case 'right':
          left = (buttonRect.right / viewportWidth) * 100;
          top = (buttonRect.top / viewportHeight) * 100;
          break;
        case 'left':
          left = (buttonRect.left / viewportWidth) * 100;
          top = (buttonRect.top / viewportHeight) * 100;
          break;
        case 'top':
          left = (buttonRect.left / viewportWidth) * 100;
          top = (buttonRect.top / viewportHeight) * 100;
          break;
        case 'bottom':
          left = (buttonRect.left / viewportWidth) * 100;
          top = (buttonRect.bottom / viewportHeight) * 100;
          break;
      }
      
      // Применяем стили с использованием viewport‑относительных единиц
      this.modalPositionStyle = {
        position: 'fixed',
        left: `${left}%`,
        top: `${top}%`,
        transform: this.getTransform(this.position),
        zIndex: 1000
      };
    },
    getTransform(position) {
      switch (position) {
        case 'right':
          return 'translate(-100%, 0)';
        case 'left':
          return 'translate(0, 0)';
        case 'top':
          return 'translate(0, 100%)';
        case 'bottom':
          return 'translate(0, -100%)';
        default:
          return 'translate(-100%, 0)';
      }
    }
  },
  mounted() {
    // Обрабатываем изменения размера окна и масштаба
    window.addEventListener('resize', this.handleResize);
  },
  beforeDestroy() {
    window.removeEventListener('resize', this.handleResize);
  },
  methods: {
    handleResize() {
      if (this.isVisible) {
        this.$nextTick(() => {
          this.calculatePosition();
        });
      }
    }
  }
}
</script>

<style scoped>
.relative-position {
  position: relative;
  display: inline-block;
}

.trigger-button {
  padding: 8px 16px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background 0.2s;
}

.trigger-button:hover {
  background: #0056b3;
}

.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 999;
}

.modal {
  background: white;
  border-radius: 8px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
  min-width: 300px;
  max-width: 500px;
  width: 90%;
  max-height: 80vh;
  overflow-y: auto;
}

.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 20px;
  border-bottom: 1px solid #eee;
}

.modal-header h3 {
  margin: 0;
  font-size: 18px;
}

.close-button {
  background: none;
  border: none;
  font-size: 24px;
  cursor: pointer;
  padding: 0;
  width: 30px;
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  transition: background 0.2s;
}

.close-button:hover {
  background: #f0f0f0;
}

.modal-body {
  padding: 20px;
}

.slide-enter-active, .slide-leave-active {
  transition: all 0.3s ease;
}

.slide-enter, .slide-leave-to {
  opacity: 0;
  transform: scale(0.9);
}
</style>

Пример реализации на VC.ru

Чтобы добиться поведения, аналогичного кнопке «Поделиться» на VC.ru, можно использовать следующий подход:

vue
<template>
  <div class="share-button-container">
    <button 
      class="share-button"
      @click="toggleShareModal"
    >
      Поделиться
    </button>
    
    <div v-if="isShareModalOpen" 
         class="share-modal"
         :style="shareModalStyle"
         @click="closeShareModal">
      <div class="share-modal-content" @click.stop>
        <div class="share-header">
          <h4>Поделиться</h4>
          <button class="close-btn" @click="closeShareModal">×</button>
        </div>
        <div class="share-options">
          <button class="share-option">
            <span class="icon">📧</span>
            <span>Email</span>
          </button>
          <button class="share-option">
            <span class="icon">📱</span>
            <span>Ссылка</span>
          </button>
          <button class="share-option">
            <span class="icon">📘</span>
            <span>Facebook</span>
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'ShareButton',
  data() {
    return {
      isShareModalOpen: false,
      shareModalStyle: {}
    }
  },
  methods: {
    toggleShareModal() {
      this.isShareModalOpen = !this.isShareModalOpen;
      if (this.isShareModalOpen) {
        this.$nextTick(() => {
          this.positionShareModal();
        });
      }
    },
    closeShareModal() {
      this.isShareModalOpen = false;
    },
    positionShareModal() {
      const button = this.$el.querySelector('.share-button');
      const modal = this.$el.querySelector('.share-modal');
      
      if (!button || !modal) return;
      
      const buttonRect = button.getBoundingClientRect();
      const viewportWidth = window.innerWidth;
      
      // Позиционируем модальное окно справа от кнопки
      const leftPercent = (buttonRect.right / viewportWidth) * 100;
      const topPercent = (buttonRect.top / window.innerHeight) * 100;
      
      this.shareModalStyle = {
        position: 'fixed',
        left: `${leftPercent}%`,
        top: `${topPercent}%`,
        transform: 'translate(-100%, 0)',
        zIndex: 1000
      };
    }
  }
}
</script>

<style scoped>
.share-button-container {
  position: relative;
}

.share-button {
  padding: 6px 12px;
  background: #f8f9fa;
  border: 1px solid #dee2e6;
  border-radius: 4px;
  font-size: 14px;
  cursor: pointer;
  transition: all 0.2s;
}

.share-button:hover {
  background: #e9ecef;
  border-color: #adb5bd;
}

.share-modal {
  position: fixed;
  background: white;
  border: 1px solid #dee2e6;
  border-radius: 6px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  min-width: 200px;
  z-index: 1001;
}

.share-modal-content {
  padding: 8px 0;
}

.share-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 16px;
  border-bottom: 1px solid #eee;
}

.share-header h4 {
  margin: 0;
  font-size: 14px;
  font-weight: 600;
}

.close-btn {
  background: none;
  border: none;
  font-size: 18px;
  cursor: pointer;
  padding: 0;
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  transition: background 0.2s;
}

.close-btn:hover {
  background: #f0f0f0;
}

.share-options {
  display: flex;
  flex-direction: column;
}

.share-option {
  display: flex;
  align-items: center;
  padding: 8px 16px;
  background: none;
  border: none;
  cursor: pointer;
  transition: background 0.2s;
  font-size: 14px;
  width: 100%;
  text-align: left;
}

.share-option:hover {
  background: #f8f9fa;
}

.share-option .icon {
  margin-right: 12px;
  font-size: 16px;
}
</style>

Альтернативные подходы

1. Использование CSS Popover API

Для современных браузеров можно использовать нативный CSS Popover API:

vue
<template>
  <button 
    popovertarget="share-popover"
    class="share-button"
  >
    Поделиться
  </button>
  
  <div id="share-popover" popover="auto" class="popover-modal">
    <div class="popover-content">
      Содержимое модального окна
    </div>
  </div>
</template>

<style>
.popover-modal {
  position: absolute;
  background: white;
  border: 1px solid #ccc;
  border-radius: 4px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  padding: 16px;
  margin-top: 8px;
  z-index: 1000;
}

.popover-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
</style>

2. Использование CSS Container Queries

Подход с использованием CSS Container Queries для адаптивного позиционирования:

vue
<template>
  <div class="button-container">
    <button class="trigger-button" @click="showModal = true">
      Открыть
    </button>
    
    <div v-if="showModal" class="modal-container">
      <div class="modal">
        Содержимое модального окна
      </div>
    </div>
  </div>
</template>

<style>
.button-container {
  container-type: inline-size;
}

@container (min-width: 200px) {
  .modal-container {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
  }
}

@container (max-width: 199px) {
  .modal-container {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }
}

.modal {
  background: white;
  border: 1px solid #ccc;
  border-radius: 4px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  padding: 16px;
}
</style>

Практические рекомендации

  1. Избегайте абсолютных пиксельных координат – всегда используйте viewport‑относительные единицы или проценты
  2. Используйте position: fixed вместо position: absolute для модальных окон, чтобы они оставались в видимой области при прокрутке
  3. Реагируйте на изменения масштаба – добавляйте обработчик события resize для пересчёта позиции при изменении размера окна
  4. Используйте CSS transforms для позиционирования, так как они лучше работают с масштабированием страницы
  5. Тестируйте в разных браузерах – убедитесь, что решение работает корректно во всех целевых браузерах
  6. Рассмотрите использование готовых библиотек таких как Vue Popper.js или Tippy.js для сложных сценариев позиционирования
  7. Обрабатывайте крайние случаи – когда модальное окно может выходить за пределы viewport, автоматически смещая его в видимую область
javascript
// Пример автоматической коррекции позиции
function adjustModalPosition(modalStyle, buttonRect, modalRect) {
  const viewportWidth = window.innerWidth;
  const viewportHeight = window.innerHeight;
  
  // Проверяем, не выходит ли модальное окно за правый край
  if (buttonRect.right + modalRect.width > viewportWidth) {
    modalStyle.transform = 'translate(0, 0)'; // Смещаем влево
  }
  
  // Проверяем, не выходит ли модальное окно за нижний край
  if (buttonRect.bottom + modalRect.height > viewportHeight) {
    modalStyle.top = `${(buttonRect.top - modalRect.height) / viewportHeight * 100}%`;
  }
}

Источники

  1. The New CSS Positioning System Explained – Подробное объяснение современных CSS систем позиционирования
  2. Modify and prevent a “bouncy” viewport effect – Анализ проблем с viewport при работе с модальными окнами
  3. Vue tricks: smart layouts for VueJS – Советы по созданию адаптивных макетов в Vue.js
  4. Official Vue.js Documentation - Component Basics – Официальная документация Vue.js по работе с компонентами
  5. CSS Viewport Units - A Complete Guide – Подробное руководство по использованию viewport‑относительных единиц в CSS

Заключение

Для создания модального окна в Vue.js, которое сохраняет позицию относительно кнопки при изменении масштаба страницы, следует избегать абсолютных пиксельных координат из getBoundingClientRect(). Вместо этого используйте viewport‑относительные единицы измерения (vw, vh, %) или CSS transforms для позиционирования.

Ключевые решения:

  • Используйте position: fixed с процентными значениями вместо абсолютных пикселей
  • Рассчитывайте позиции на основе доли от viewport, а не абсолютных координат
  • Добавляйте обработчик события resize для автоматической коррекции позиции
  • Рассмотрите использование CSS Popover API для современных браузеров
  • Тестируйте решение в разных условиях масштабирования

Такой подход обеспечит стабильное поведение модального окна при любых изменениях масштаба страницы и предотвратит «убегание» от кнопки‑триггера.

Авторы
Проверено модерацией
Модерация