Как сверстать форму обратной связи

Эта статья основана на одном из практических заданий verstaem.online.

Вы можете читать материал как обычное руководство или сначала попробовать выполнить задание самостоятельно, а затем сравнить свое решение с подробным разбором.

Перейти к заданию

В этом уроке мы создадим страницу обратной связи с двумя колонками. Слева разместим форму с полями ввода, текстовой областью и кастомным выпадающим списком на базе библиотеки Choices.js, а справа оформим информационные карточки с контактами и этапами обработки обращения. По пути научимся подключать сторонние библиотеки и стилизовать их под собственный дизайн.

Создаем HTML-разметку

Сначала создадим базовую HTML-разметку с помощью Emmet. Для этого достаточно ввести ! и нажать Tab. Обратите внимание на атрибут lang. Поскольку основной контент страницы написан на русском языке, здесь используем значение ru.

<!doctype html>
<html lang="ru">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Форма обратной связи</title>
  </head>
  <body>
  </body>
</html>

Итак, давайте работать с контентом. Создадим блок с классом content, а внутрь положим два других — левый и правый. Как раз левый и будет нашей формой. Добавляем ее, и не забываем добавить два атрибута — action и method. В первый мы вставим ссылку из задания https://postman-echo.com/post, а во второй вставим POST. Так мы сможем проверить, все ли правильно улетает из нашей формы.

А вот ниже пойдут поля формы. У полей есть подписи сверху, поэтому правильно будет эти подписи связать с инпутом. Можно сделать эту подпись тегом <label>, и связать с полем через атрибут for, но как по мне, просто обернуть и текст, и поле в <label> гораздо проще. Так и поступим. И обязательно укажем все важные атрибуты для поля ввода. Правильный type, заполненный name, placeholder и required.

Помимо инпутов в нашей форме будет еще и селект. Как его стилизовать — отдельный вопрос, но сейчас мы должны правильно разметить его. Добавляем селект, заполняем name, и добавляем теги <option> внутрь. Обязательно нужно делать значение атрибута value на английском языке без пробелов. Именно такой формат «любит» сервер, которому будет доставляться форма.

И еще одно — это, конечно, <textarea>. Это блок, похожий на инпут, но все же с отличиями, и используется он для многострочного ввода. Мы дадим ему стили инпута, но через дополнительный класс добавим то, чего не хватает.

<div class="content__items">
  <label class="content__label">
    <span class="content__caption">Имя</span>
    <input
      type="text"
      name="name"
      autocomplete="name"
      placeholder="Иван"
      class="content__input"
      required
    />
  </label>
  <label class="content__label">
    <span class="content__caption">E-mail</span>
    <input
      type="email"
      name="email"
      autocomplete="email"
      inputmode="email"
      placeholder="mail@example.com"
      class="content__input"
      required
    />
  </label>
  <label class="content__label">
    <span class="content__caption">Тема обращения</span>
    <select name="topic" class="content__select">
      <option value="technical">Техническая проблема</option>
      <option value="payment">Вопрос по оплате</option>
      <option value="partnership">Сотрудничество</option>
      <option value="other">Другое</option>
    </select>
  </label>
  <label class="content__label">
    <span class="content__caption">Номер заказа</span>
    <input
      type="number"
      name="order_number"
      autocomplete="off"
      inputmode="numeric"
      placeholder="123456"
      class="content__input"
      required
    />
  </label>
  <label class="content__label content__label--full">
    <span class="content__caption">Сообщение</span>
    <textarea name="message" class="content__input content__input--textarea" placeholder="Опишите вашу проблему..." spellcheck="true"></textarea>
  </label>
</div>

Атрибут name нужен, если мы хотим, чтобы из нашей формы вообще что-то отправилось. Без него поле вообще не войдет в «отправляемые». required мы добавили, чтобы поля были обязательными, ведь вход не может быть произведен, если не введена почта и пароль.

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

<div class="content__addition">
  <label class="custom-checkbox">
    <input type="checkbox" name="accept_terms" class="custom-checkbox__input" required />
    <span class="custom-checkbox__text"
      >Я принимаю условия использования сервиса и политику обработки
      персональных данных.</span
    >
  </label>
</div>

Остается кнопка, и подпись к ней. Вот у кнопки отправки мы явно добавим type="submit".

<button class="content__btn" type="submit">Отправить</button>

Помимо формы, у нас есть еще и правая часть. В ней сделаем два блока — дива, в которых будет заголовок и список. Раздаем им разные классы, потому что внешне они разные. И конечно, не забудем про то, что телефон и e-mail делаем с помощью ссылок.

<div class="content__info">
  <div class="content__block">
    <h2 class="content__subtitle">
      Контакты поддержки
    </h2>
    <ul class="content__contacts">
      <li class="content__contacts-item">
        <div class="content__contacts-caption">
          E-mail
        </div>
        <a href="mailto:support@orbit.app" class="content__contacts-text">
          support@orbit.app
        </a>
      </li>
      <li class="content__contacts-item">
        <div class="content__contacts-caption">
          Телефон
        </div>
        <a href="tel:+78005551825" class="content__contacts-text">
          +7 (800) 555‑18‑25
        </a>
      </li>
      <li class="content__contacts-item">
        <div class="content__contacts-caption">
          Время работы
        </div>
        <div class="content__contacts-text">
          09:00 — 21:00 ежедневно
        </div>
      </li>
    </ul>
  </div>
  <div class="content__block">
    <h2 class="content__subtitle">
      Что дальше?
    </h2>
    <ul class="content__more">
      <li class="content__more-item">
        Заявка попадает в очередь поддержки.
      </li>
      <li class="content__more-item">
        Мы анализируем обращение и уточняем детали при необходимости.
      </li>
      <li class="content__more-item">
        Ответ приходит на указанную электронную почту.
      </li>
    </ul>
  </div>
</div>

Добавляем стили

Подключим файл стилей через <link> и зададим несколько базовых правил. Для этого задания достаточно переопределить box-sizing, сбросить отступ у <body> и подключить шрифт Segoe UI, который используется в макете.

* {
  box-sizing: border-box;
}

@font-face {
  font-family: "Segoe UI";
  src: url("../fonts/SegoeUI.woff2");
  font-display: swap;
  font-weight: 400;
}

@font-face {
  font-family: "Segoe UI";
  src: url("../fonts/SegoeUIBold.woff2");
  font-display: swap;
  font-weight: 700;
}

body {
  margin: 0;
  padding: 20px;
  background: #0d1220;
  font-family: "Segoe UI", sans-serif;
}

Далее быстро расставим элементы на места и зададим им фон.

.content {
  display: flex;
  align-items: flex-start;
  gap: 24px;
  margin: 0 auto;
  max-width: 1180px;
}

.content__info {
  display: flex;
  flex-direction: column;
  gap: 24px;
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 32px;
  padding: 32px;
  width: 466px;
  background: #151d31;
}

.content__form {
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 32px;
  padding: 40px;
  width: calc(100% - 466px - 24px);
  background: #151d31;
}

Здесь я решил сделать правую часть с фиксированным размером, а левую — гибкой. Для этого я использую ширину через calc() и вычитаю из 100% и ширину правой части, и размер отступа. Можно сделать и наоборот, пусть форма будет фиксированной. Тут как вам удобно.

Теперь текстовые элементы у формы. Берем все из макета.

.content__title {
  margin: 0;
  margin-bottom: 16px;
  font-weight: 700;
  font-size: 40px;
  line-height: 120%;
  color: #eef3ff;
}

.content__descr {
  margin: 0;
  margin-bottom: 40px;
  font-weight: 400;
  font-size: 16px;
  line-height: 120%;
  color: rgba(155, 167, 199, 0.92);
}

Переходим к стилизации формы, к самому интересному. Обертке полей даем flexbox и flex-wrap: wrap. Лейблу сделаем flex, чтобы расставить элементы внутри, а также зададим ширину через calc, чтобы учитывать отступ.

У инпута нам нужны будут отступы и слева, и справа, чтобы контент не лип к краям, также нужна будет высота из макета. И конечно, не забудем указать font-family: inherit, иначе шрифт не применится. Также мы добавим псевдокласс ::placeholder, который позволит нам стилизовать цвет текста, который мы видим еще до ввода своего.

И сразу же, с помощью класса .content__input--textarea, изменим отступы, высоту и скроем кнопку ресайза.

.content__items {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
}

.content__label {
  display: flex;
  flex-direction: column;
  width: calc(50% - 8px);
  gap: 8px;
}

.content__label--full {
  width: 100%;
}

.content__caption {
  font-weight: 400;
  font-size: 12px;
  line-height: 120%;
  color: #eef3ff;
}

.content__input {
  border: 1px solid #2b3656;
  border-radius: 16px;
  padding: 0px 16px;
  height: 54px;
  font-family: inherit;
  font-weight: 400;
  font-size: 16px;
  line-height: 120%;
  color: #fff;
  background: #0f1628;
}

.content__input--textarea {
  padding: 16px;
  resize: none;
  height: 90px;
}

.content__input::placeholder {
  color: #fff;
}

Далее блок с чекбоксом. Ему дадим flex. А вот сам чекбокс — это интересно. По сути, управлять всем будет <input>, но мы скроем его, заменив на свое представление.

.content__addition {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-top: 16px;
  margin-bottom: 16px;
  gap: 20px;
}

.custom-checkbox {
  position: relative;
  max-width: 460px;
  cursor: pointer;
}

.custom-checkbox__input {
  position: absolute;
  opacity: 0;
  appearance: none;
}

.custom-checkbox__text {
  position: relative;
  display: inline-flex;
  font-weight: 400;
  font-size: 16px;
  line-height: 120%;
  color: #eef3ff;
  gap: 8px;
}

.custom-checkbox__text::before {
  content: "";
  display: block;
  border: 1px solid #eef3ff;
  border-radius: 4px;
  flex-shrink: 0;
  width: 16px;
  height: 16px;
}

.custom-checkbox__text::after {
  content: "";
  position: absolute;
  left: 5px;
  top: 5px;
  border-radius: 2px;
  width: 8px;
  height: 8px;
  background: #eef3ff;
  opacity: 0;
  transition: opacity 0.3s;
}

.custom-checkbox__input:checked + .custom-checkbox__text::after {
  opacity: 1;
}

.custom-checkbox__input:focus + .custom-checkbox__text {
  outline: 1px solid #eef3ff;
  outline-offset: 3px;
  border-radius: 2px;
}

Давайте разберем, что же тут сделано. Ну, во-первых, мы даем cursor: pointer для лейбла, делая его как бы кликабельным. Затем явно скрываем инпут с помощью opacity и appearance. Оно как раз и нужно, чтобы сбросить внешний вид элемента.

Далее задаем стили тексту, в том числе flexbox. И вот тут начинается магия — мы делаем псевдоэлемент ::before для текста, и рисуем с его помощью чекбокс — по сути, просто квадрат с обводкой. А с помощью ::after рисуем квадратик, который будет внутри, то есть будет обозначать, отмечен чекбокс или нет. Этот квадратик мы по умолчанию скроем с помощью opacity.

И теперь, как я и говорил, всем будет управлять скрытый <input>. Важно не скрывать его с помощью display: none, иначе он не будет работать.

.custom-checkbox__input:checked + .custom-checkbox__text::after {
  opacity: 1;
}

Здесь мы явно говорим «если чекбокс отмечен, найди следующий элемент (через +), и у него псевдоэлементу ::after задай opacity: 1„. Мы используем фишку с комбинаторными селекторами, и находим следующий за инпутом элемент через селектор „+“. Как вы понимаете, если бы структура html была другой, это бы просто не сработало. Имейте это ввиду.

Далее сделаем кнопку. Тут все просто, берем из макета, главное не забывайте про font-family: inherit.

.content__btn {
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 20px;
  padding: 16px;
  width: 100%;
  min-height: 58px;
  font-family: inherit;
  font-weight: 700;
  font-size: 16px;
  line-height: 120%;
  color: #fff;
  background: #6f7cff;
  cursor: pointer;
  transition: background 0.3s;
}

.content__btn:hover {
  background: #4f5de6;
}

Стилизация селекта

Остается последнее, но не самое простое занятие — это стилизация селекта. Стилизовать селекты по-прежнему кроссбраузерно невозможно, поэтому это делается через Js-библиотеки, которые по сути скрывают обычный селект, и вокруг него строят свой, кастомный. В нашем проекте уже есть папка libs, в которой лежат нужные файлы библиотеки Choices. Подключим файлы внутри <head>.

<link rel="stylesheet" href="libs/choices.min.css" />
<script src="libs/choices.min.js" defer></script>
<script src="js/script.js" defer></script>

Обязательно добавляем атрибут defer для <script>, чтобы js-код грузился только после загрузки html-кода. И также важно, чтобы script.js был подключен ниже, чем файл библиотеки. Это же касается и стилей в style.css. Всегда сперва библиотеки, а потом уже наши файлы.

Теперь пойдем в script.js и напишем простейший скрипт.

const element = document.querySelector('.content__select');
const choices = new Choices(element, {
  searchEnabled: false,
  itemSelectText: '',
});

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

Но как менять стили? Самое важное правило — менять стили нужно, используя точно такие же селекторы стилей, как и у библиотеки. Их можно увидеть в devtools. Итак, нам нужно поменять основные стили селекта, сделать их похожими на инпут.

.choices[data-type="select-one"] .choices__inner {
  display: flex;
  align-items: center;
  border: 1px solid #2b3656;
  border-radius: 16px;
  padding: 0px 16px;
  height: 54px;
  font-family: inherit;
  font-weight: 400;
  font-size: 16px;
  line-height: 120%;
  color: #fff;
  background: #0f1628;
}

Селектор немаленький, сложный, но именно такой, как в devtools. Только так мы можем быть уверены, что наши стили «перебьют» стили библиотеки. Иначе не хватит веса селектора.

Далее добавим отступы для dropdown-части, а также обозначим внешний вид при состоянии фокуса. После этого скроем стрелочку по умолчанию (она сделана через border, поэтому достаточно указать border: none), и покажем свою, свгшную. Чтобы сделать абсолютный элемент по центру по вертикали — указываем top: 50% и translate: 0 -50%. Так мы сдвигаем элемент вниз на 50%, а затем возращаем назад на 50% своей высоты, так элемент и встает по центру.

.choices__list {
  padding: 8px 0;
}

.choices[data-type*=select-one]:focus-visible {
  border-radius: 16px;
  outline: 1px solid #fff;
}

.choices[data-type*=select-one]::after {
  right: 16px;
  top: 50%;
  margin: 0;
  border: none;
  width: 16px;
  height: 16px;
  background-image: url("../img/angle.svg");
  background-position: center;
  background-size: cover;
  background-repeat: no-repeat;
  translate: 0 -50%;
}

Далее через класс is-open сделаем разворот стрелочки, а также сбросим margin, которые библиотека добавляет. Затем, добавим стили для дропдаун части — фон, обводку, отступ от селекта из макета.

.choices[data-type*=select-one].is-open::after {
  margin: 0;
  rotate: 180deg;
}

.choices__list[aria-expanded] {
  z-index: 2;
  margin-top: 4px;
  border: 1px solid #2b3656 !important;
  border-radius: 16px;
  background: #0f1628;
}

Библиотека рассчитана так, что выпадающая часть может открыться не только вниз, но и вверх, если высоты браузера не хватает. Это управляется классом is-flipped, поэтому мы должны добавить стили и для него.

.is-flipped .choices__list--dropdown,
.is-flipped .choices__list[aria-expanded] {
  margin-bottom: 4px;
  border-radius: 16px;
}

Теперь можем стилизовать элементы дропдауна, и сразу же — состояние и при наведении, и при активности.

.choices__list[aria-expanded] .choices__item {
  padding: 8px 16px;
  font-size: 16px;
  color: #fff;
}

.choices__list--dropdown .choices__item--selectable.is-highlighted,
.choices__list--dropdown .choices__item--selectable.is-selected,
.choices__list[aria-expanded] .choices__item--selectable.is-highlighted,
.choices__list[aria-expanded] .choices__item--selectable.is-selected {
  background: #6f7cff;
}

Стилизация правой части

Осталась правая часть. Стилизуем сперва блоки, затем заголовок.

.content__block {
  border-radius: 20px;
  padding: 20px;
  background: #0f1628;
}

.content__subtitle {
  margin: 0;
  margin-bottom: 16px;
  font-weight: 700;
  font-size: 20px;
  line-height: 120%;
  color: #eef3ff;
}

Теперь внутренний список контактов. Здесь все берем из макета, но есть лишь одна важная мелочь — это состояние при наведении. Поскольку в первых двух элементах списка ссылки, а в третьем — нет, мы не можем просто задать наведение для всего. Но мы можем улучшить это, сделав селектор сложнее: .content__contacts-text[href]:hover. Таким образом, мы делаем наведение только на тот элемент, который имеет атрибут href.

.content__contacts {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.content__contacts-item {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 4px;
}

.content__contacts-caption {
  font-weight: 400;
  font-size: 12px;
  line-height: 120%;
  color: #8b97b8;
}

.content__contacts-text {
  font-weight: 400;
  font-size: 16px;
  line-height: 120%;
  text-decoration: none;
  color: #c9d2eb;
  transition: color 0.3s;
}

.content__contacts-text[href]:hover {
  color: #fff;
}

Теперь нижний список. Тоже ничего сложного, но без мелочей не обойдется.

.content__more {
  counter-reset: items;
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
}

.content__more-item {
  display: flex;
  align-items: center;
  padding: 12px 0;
  font-weight: 400;
  font-size: 16px;
  line-height: 120%;
  color: #eef3ff;
  gap: 12px;
}

.content__more-item:not(:last-child) {
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}

.content__more-item::before {
  counter-increment: items;
  content: counter(items);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  border-radius: 100px;
  width: 28px;
  height: 28px;
  font-weight: 400;
  font-size: 12px;
  line-height: 1;
  color: #eef3ff;
  background: #2a3760;
}

Первая мелочь — это цифры у элементов списка. Делать их через html странно, ведь если элементы добавятся или убавятся — придется менять цифры. Но с помощью CSS Counters мы можем сделать это автоматически. У списка задаем counter-reset: items;, а элементам списка создаем псевдоэлемент, со стилями counter-increment: items; content: counter(items);. Вместо items можете писать что угодно, главное чтобы везде значение совпадало.

Вторая мелочь — сброс обводки у последнего элемента. А точнее, делая селектор .content__more-item:not(:last-child), мы идем наоборот. Мы задаем обводку всем элементам, кроме последнего.

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

Решение задачи

предыдущий пост следующий пост