Галерея изображений с увеличением по клику

alexei19/10/2021 - 09:23
Галерея изображений с увеличением по клику

Одним из вариантов использования CSS-сетки является отображение галереи изображений, но галерея сама по себе не так уж и впечатляет. Но мы могли бы, например, добавить эффект, когда при нажатии мышкой на изображение галереи, оно будет увеличиваться, не влияя на всю сетку. Это уже будет интереснее. И, конечно, чтобы все могли пользоваться этой функцией, мы должны сделать так, чтобы галерея соответствовала принципам доступности.

В этой статье мы объясним, как создать доступную галерею с увеличивающимися по клику изображениями. Вот так будет выглядеть конечный результат:

HTML

Сначала зададим структуру HTML. Конечно, подобная структура может быть абсолютно любой, но мы будем использовать список изображений, завернутых в кнопки.


<ul class="js-favs">
  <li>
    <button>
      <img src="//msiter.ru/image" alt="" />
    </button>
  </li>
  ...
</ul>

Теперь, чтобы сделать галерею доступной, нам нужно внести некоторые коррективы:

  • Добавьте описательный атрибут alt к каждому изображению, чтобы помочь людям с нарушениями зрения понять, что изображено на картинке;
  • Используйте атрибут aria-expanded, который информирует ассистивные системы о том, расширяется ли изображение или нет;
  • Добавьте role="list", чтобы убедиться, что ассистивные технологии объявляют список, потому что некоторые программы чтения с экрана могут удалять объявление списка.

Наконец, добавим абзац с полезным текстом о том, как использовать галерею, и обернем весь код в контейнер (в данном случае это элемент main).


<main>
  <p>Нажмите ESC, чтобы закрыть большое изображение.</p>
  <ul class="js-favs" role=”list”>
    <li>
      <button aria-expanded="false">
        <img src="//msiter.ru/image" alt="Описание изображения." />
      </button>
    </li>
    ...
  </ul>
</main>

Для простоты демонстрации мы решили использовать изображения, обернутые элементом кнопки с атрибутом aria-expanded. Лучшим решением здесь может быть использование только тегов изображений, а затем при помощи JavaScript оборачивать их элементом <button> с атрибутом aria-expanded. Такой подход более прогрессивен, так как эффект увеличения все равно не будет работать без JavaScript.

CSS

Чтобы определить макет сетки, мы будем использовать модуль CSS Grid. При этом мы будем использовать auto-fit, чтобы изображения могли поместиться в доступном пространстве, но не уменьшаться до определенной ширины. Это означает, что у нас будет разное количество элементов на разных экранах вывода без необходимости писать слишком много кода в медиа-запросах.


:root {
  --gap: 4px;
}

ul {
  display: grid;
  grid-template-columns: repeat(1, 1fr);
  grid-gap: var(--gap);
}

@media screen and (min-width: 640px) {
  ul {
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  }
}

Чтобы сохранить правильное соотношение сторон изображения, будем использовать свойство aspect-ratio. Чтобы сбросить стиль кнопки, добавим декларацию all: initial. Также нужно скрыть переполнение кнопки.

Чтобы изображение поместилось прямо в кнопку, будем использовать свойство object-fit: cover и установим ширину и высоту (width и height) в 100%:


button {
  all: initial;
  display: block;
  width: 100%;
  aspect-ratio: 2/1;
  overflow: hidden;
  cursor: pointer;
}

img {
  height: 100%;
  width: 100%;
  object-fit: cover;
}

Эффект увеличения достигается при помощи масштабируемой трансформации. Трансформация по умолчанию включена, но если пользователь не хочет видеть анимацию, то можно использовать медиа-запрос со значением prefers-reduced-motion и установить для свойства transition-duration значение 0s.


:root {
  --duration-shrink: .5s;
  --duration-expand: .25s;
  --no-duration: 0s;
}

li {
  transition-property: transform, opacity;
  transition-timing-function: ease-in-out;
  transition-duration: var(--duration-expand);
}

li.is-zoomed {
  transition-duration: var(--duration-shrink);
}

@media (prefers-reduced-motion) {
  li,
  li.is-zoomed {    
    transition-duration: var(--no-duration);
  }
}

JavaScript

Подготовка

Прежде чем мы сделаем элемент увеличивающимся, нам нужно подготовить и рассчитать несколько вещей.

Во-первых, нам нужно будет знать длительность трансформации. Для этого нужно прочитать пользовательское свойство CSS --duration-on.


let timeout = 0

// Получает время трансформации из CSS
const getTimeouts = () => {
  const durationOn = parseFloat(getComputedStyle(document.documentElement)
    .getPropertyValue('--duration-on'));
  
  timeout = parseFloat(durationOn) * 1000
}

Затем установим атрибуты data- для дальнейших вычислений:

  • отступ между элементами в сетке;
  • ширину отдельного элемента;
  • количество элементов в ряду.

Первые два довольно просты. Мы можем получить эти значения из вычисленных стилей CSS.

Чтобы найти количество столбцов, мы должны перебрать каждый элемент и сравнить его верхнюю позицию с верхней позицией следующего элемента. Изменение этого значения означает, что элемент находится в следующем ряду, что и даст нам количество элементов в строке.


// Устанавливаем атрибуты data для дальнейших вычислений
const setDataAttrs = ($elems, $parent) => {
  // Получаем верхнюю позицию первого элемента
  let top = getTop($elems[0])

  // Устанавливаем отступ в сетке из CSS
  const gridColumnGap = parseFloat(getComputedStyle(document.documentElement)
    .getPropertyValue('--gap'))
  $parent.setAttribute('data-gap', gridColumnGap)

  // Устанавливаем ширину элемента из CSS
  const eStyle = getComputedStyle($elems[0])
  $parent.setAttribute('data-width', eStyle.width)

  // Проходимся по элементам сетки
  for (let i = 0; i < $elems.length; i++) {
    const t = getTop($elems[i])

    // Проверяем изменения верхней позиции
    if (t != top) {
      // Устанавливаем количество колонок и прерываем цикл
      $parent.setAttribute('data-cols', i)
      break;
    }
  }
}

Направление увеличения

Прежде чем получить эффект увеличения изображения, нам нужно провести некоторые проверки и расчеты. Во-первых, мы должны проверить, находится ли элемент в последней строке и в конце строки. Если элемент находится в последней строке, он должен увеличиваться вверх. Это означает, что свойству transform-origin следует присвоить значение bottom.

Важно! Если элемент должен увеличиваться в какую-то сторону, его свойству transform-origin следует присваивать "противоположное" значение. Обратите внимание, что вертикальные и горизонтальные значения должны быть объединены.


// Установим активный элемент
const activateElem = ($elems, $parent, $elem, $button, lengthOfElems, i) => {
  // Получим атрибуты data у родителя
  const cols = parseInt($parent.getAttribute('data-cols'))
  const width = parseFloat($parent.getAttribute('data-width'))
  const gap = parseFloat($parent.getAttribute('data-gap'))

  // Вычислим количество рядов
  const rows = Math.ceil(lengthOfElems / cols) - 1

  // Определим, находится ли элемент в последнем ряду
  const isLastRow = i + 1 > rows * cols
  // Установим направление трансформации по умолчанию вверх (увеличение вниз) 
  let transformOrigin = 'top'

  if (isLastRow) {
    // Если элемент находится в последнем ряду, установим
    // направление трансформации вниз (увеличение вверх)
    transformOrigin = 'bottom'
  }

  // Определим, является ли элемент самым правым
  const isRight = (i + 1) % cols !== 0

  if (isRight) {
    // Если элемент самый правый, установим направление
    // трансформации влево (увеличение вправо)
    transformOrigin += ' left'
  } else {
    // В обратном случае установим направление
    // трансформации вправо (увеличение влево)
    transformOrigin += ' right'
  }

  $elem.style.transformOrigin = transformOrigin
}

Эффект увеличения

Чтобы увеличить изображение, не затрагивая сетку, будем использовать CSS-трансформацию. В частности, трансформацию масштаба. Здесь мы решили сделать изображение двойным по размеру, т.е. отношение двойной ширины элемента плюс отступ между элементами в сетке и будет коэффициентом масштабирования.


// Вычислим коэффициент масштабирования
const scale = (width * 2 + gap) / width

// Установим CSS свойство transform
$elem.style.transform = `scale(${scale})`

Поддержка клавиатуры

Пользователи, которые для навигации по сайтам используют клавиатуру, также должны иметь возможность пользоваться галереей. В нашей галерее перемещение по списку изображений будет осуществляться при помощи клавиши Tab. Нажатие на изображение кнопкой мыши будет эмулироваться при нажатии на клавишу Enter во время фокусировки элемента. Также, чтобы улучшить поведение по умолчанию, мы должны добавить поддержку клавиши Esc и клавиш со стрелками.

Как только изображение будет увеличено, нажатие на клавишу Esc должно вернуть его к стандартному размеру. Это можно сделать, проверив код нажатой клавиши. То же самое касается и клавиш со стрелками, но реакция галереи будет другой. При нажатии на клавиши со стрелками должен осуществляться переход на предыдущее или следующее соседнее изображение, а затем эмулироваться нажатие на этот элемент.


// Установим соседний элемент как активный
const activateSibling = ($sibling) => {
  // Найдем анкор
  const $siblingButton = $sibling.querySelector('button')

  // Сбросим глобальный активный элемент
  $activeElem = false

  // Фокусируем и кликаем на текущем элементе
  $siblingButton.focus()
  $siblingButton.click()
}

// Устанавливаем обработчик нажатий на клавиатуру
const setKeyboardEvents = () => {
  document.addEventListener('keydown', (e) => {
    // Предпринимаем действия только если глобальный активный элемент существует
    if ($activeElem) {
      // Если клавиша "Esc", эмулируем нажатие на глобальный активный элемент
      if (e.code === 'Escape') {
        $activeElem.click()
      }

      // Если клавиша "стрелка влево", активируем предыдущий элемент
      if (e.code === 'ArrowLeft') {
        const $previousSibling = $activeElem.parentNode.previousElementSibling

        if($previousSibling) {
          activateSibling($previousSibling)
        }
      }

      // Если клавиша "стрелка вправо", активируем следующий элемент
      if (e.code === 'ArrowRight') {
        const $nextSibling = $activeElem.parentNode.nextElementSibling

        if($nextSibling) {
          activateSibling($nextSibling)
        }
      }
    }
  })
}

Переключение

Чтобы элемент галереи был увеличен, сначала нужно деактивировать все остальные элементы. Затем, если мы нажмем на увеличенный элемент, он должен вернуться к стандартному размеру.


let $activeElem = false

// Деактивируем элементы сетки
const deactiveElems = ($elems, $parent, $currentElem, $button) => {
  // Сбросим класс родителя
  $parent.classList.remove('is-zoomed')

  for (let i = 0; i < $elems.length; i++) {
    // Сбросим класс элемента
    $elems[i].classList.remove('is-zoomed')
    // Сбросим CSS трансформацию элемента
    $elems[i].style.transform = 'none'

    // Пропускаем, если это текущий элемент
    if ($elems[i] === $currentElem) {
      continue
    }
      
    // Сбросим aria-expanded, если элемент существует
    if($button) {
      $button.setAttribute('aria-expanded', false)
    }
  }
}

// Установим активный элемент
const activateElem = ($elems, $parent, $elem, $button, lengthOfElems, i) => {
  ...
  
  // Сбросим все элементы
  deactiveElems($elems, $parent, $elem, $button)

  if ($activeElem) {
    $activeElem = false
    return
  }

  $activeElem = $button
  
  ...
}

// Установим обработчик нажатия на анкоры
const setClicks = ($elems, $parent) => {
  $elems.forEach(($elem, i) => {
    // Найдем анкор
    const $button = $elem.querySelector('button')

    $button.addEventListener('click', (e) => {
      // Установим активный элемент по нажатию
      activateElem($elems, $parent, $elem, $button, $elems.length, i)
    })
  })
}

Проблемы z-index

Чтобы предотвратить проблемы с z-index и контекстом наложения, мы будем использовать тайм-аут для задержки трансформации изображения. Это тот же самый тайм-аут, который мы рассчитали на этапе подготовки.


// Деактивируем элементы сетки
const deactiveElems = ($elems, $parent, $currentElem, $button) => {
  for (let i = 0; i < $elems.length; i++) {
    ...

    // По истечении половины времени ожидания, сбрасываем
    // CSS z-index, чтобы избежать проблем с наложением
    setTimeout(() => {
      $elems[i].style.zIndex = 0
    }, timeout)
  }
}

// Установим активный элемент
const activateElem = ($elems, $parent, $elem, $button, lengthOfElems, i) => {
  ...
  setTimeout(() => {
    // Установим класс родителя
    $parent.classList.add('is-zoomed')
    // Установим класс элемента
    $elem.classList.add('is-zoomed')
    // Установим CSS трансформацию элемента
    $elem.style.transform = `scale(${scale})`
    // Установим атрибут aria-expanded
    $button.setAttribute('aria-expanded', true)
    // Установим глобальный активный элемент
    $activeElem = $button
  }, timeout)
}

Изменение размера окна просмотра

Если окно просмотра изменяет размер, нам нужно пересчитать значения по умолчанию, потому что мы определили подвижную сетку, которая позволяет элементам заполнять доступное пространство и перемещаться из строки в строку.


// Установим обработчик события изменения размера окна
const setResizeEvents = ($elems, $parent) => {
  window.addEventListener('resize', () => {
    // Установим атрибуты data- для вычислений
    setDataAttrs($elems, $parent)
    // Деактивируем элементы сетки
    deactiveElems($elems, $parent)
  })
}