Выставление рейтинга

Несколько лет назад я реализовывал подобный элемент — выставление рейтинга пользователем. И вот вновь столкнулся с подобной задачей. Но вариант копирования решения со старого проекта не рассматривался, ведь там просто сборище дивов и куча jQuery (а по-другому я и не умел). Поэтому, начал рассуждать, как сделать лучше.

Кроме непосредственных звёздочек есть ещё и поле для комментария, что помогает прийти к мысли о том, что это элемент формы. Рейтинг же — это просто число, нажимая на звёздочку мы выбираем одно из множества. Так это же радиобаттон! Делаем базовую разметку и стилизацию.

<div class="rating-count">
  <input id="c1" type="radio" name="rating" value="1">
  <label for="c1">1</label>
  <input id="c2" type="radio" name="rating" value="2">
  <label for="c2">2</label>
  <input id="c3" type="radio" name="rating" value="3">
  <label for="c3">3</label>
  <input id="c4" type="radio" name="rating" value="4">
  <label for="c4">4</label>
  <input id="c5" type="radio" name="rating" value="5">
  <label for="c5">5</label>
</div>    
.rating-count {
  display: flex;
}

.rating-count input {
  display: none;
}

.rating-count input + label {
  font-size: 0;
  display: block;
  width: 22px;
  height: 21px;
  cursor: pointer;
  background: url(../img/rating-star.svg) no-repeat center / contain,
              url(../img/rating-star_fill.svg) no-repeat center / 0 0;
              /* небольшой приём для предзагрузки изображения,
                 дабы не делать спрайт и инлайном не вставлять SVG */
}

.rating-count input + label:hover,
.rating-count input:checked + label {
  background-image: url(../img/rating-star_fill.svg);
}    

Отлично, выбор работает, сохраняется, но нужно, чтобы и предыдущие звёздочки тоже загорались, а CSS не может смотреть на следующих соседей, только на предыдущих. Не беда, меняем порядок в разметке, а внешний вид восстаналиваем при помощи flex-direction: row-reverse, и используем отношение между селекторами ~.

.rating-count {
  display: flex;
  flex-direction: row-reverse;
}

.rating-count input + label:hover,
.rating-count input + label:hover ~ label,
.rating-count input:checked + label,
.rating-count input:checked + label ~ label {
  background-image: url(../img/rating-star_fill.svg);
}

Теперь ховер работает так, как надо. Но когда мы поставили оценку и захотели её поменять, то хочется, чтобы при ховере не учитывалась выбранная оценка, т. е. механика была такая же, как и при отсутствии выбора. Для этого можно воспользоваться ховером по контейнеру, в итоге получается такой замысловатый CSS.

.rating-count input + label:hover,
.rating-count input + label:hover ~ label,
.rating-count:not(:hover) input:checked + label,
.rating-count:not(:hover) input:checked + label ~ label {
  background-image: url(../img/rating-star_fill.svg);
}

Вот и всё. JavaScript? А не нужен он здесь.


Когда уже почти закончил заметку, в голову пришёл вопрос о доступности. А ведь не работает данный элемент с клавиатуры! Начинаем чинить. Первым делом уберём display: none у инпутов, спрятав их доступно, а также добавим визуальное состояние для элемента в фокусе.

.rating-count input {
  position: absolute;
  clip: rect(0 0 0 0);
  overflow: hidden;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
}

.rating-count input + label {
  background: url(../img/rating-star.svg) no-repeat center / contain,
              url(../img/rating-star_fill.svg) no-repeat center / 0 0,
              url(../img/rating-star_focus.svg) no-repeat center / 0 0;
}
    
.rating-count:not(:hover) input:focus + label {
  background-image: url(../img/rating-star_focus.svg);
}

Работает, но задом наперёд, спасибо row-reverse за это. Использовать tabindex не комильфо. Что ж, возвращаем прямой порядок, убираем row-reverse, стилизуем так же при помощи ~, но хитрее, используя вспомогательный input. Как заметили в комментариях, данный способ не позволяет получить изначальный фокус с клавиатуры. Используем сочетание псевдокласса invalid и атрибута required.

<div class="rating-count">
  <input id="c1" type="radio" name="rating" value="1" required>
  <label for="c1">1</label>
  <input id="c2" type="radio" name="rating" value="2">
  ...
</div>

.rating-count input + label {
  background-image: url(../img/rating-star_fill.svg);
}

.rating-count label:hover ~ label,
.rating-count:not(:hover) input:checked + label ~ label,
.rating-count:not(:hover) input:invalid + label {
  background-image: url(../img/rating-star.svg);
}
    
.rating-count input + label:hover,
.rating-count:not(:hover) input:focus + label {
  background-image: url(../img/rating-star_focus.svg);
}

Получилась комбинация из not, hover, focus, checked, invalid, + и ~. Вот она, мощь HTML и CSS. Всё работает, работает доступно, без избыточной разметки и без JavaScript.

Итоговый код

<div class="rating-count">
  <input id="c1" type="radio" name="rating" value="1" required>
  <label for="c1">1</label>
  <input id="c2" type="radio" name="rating" value="2">
  <label for="c2">2</label>
  <input id="c3" type="radio" name="rating" value="3">
  <label for="c3">3</label>
  <input id="c4" type="radio" name="rating" value="4">
  <label for="c4">4</label>
  <input id="c5" type="radio" name="rating" value="5">
  <label for="c5">5</label>
</div>
.rating-count {
  display: flex;
  width: 110px;
}

.rating-count input {
  position: absolute;
  overflow: hidden;
  clip: rect(0 0 0 0);
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
}

.rating-count input + label {
  font-size: 0;
  display: block;
  width: 22px;
  height: 21px;
  cursor: pointer;
  background-image: url(../img/rating-star_fill.svg);
}

.rating-count label:hover ~ label,
.rating-count:not(:hover) input:invalid + label,
.rating-count:not(:hover) input:checked + label ~ label {
  background: url(../img/rating-star.svg) no-repeat center / contain,
              url(../img/rating-star_fill.svg) no-repeat center / 0 0,
              url(../img/rating-star_focus.svg) no-repeat center / 0 0;
}
    
.rating-count input + label:hover,
.rating-count:not(:hover) input:focus + label {
  background-image: url(../img/rating-star_focus.svg);
}
{{ message }}

{{ 'Comments are closed.' | trans }}