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

О чем пойдет речь

Даже до первого клика по кнопке, до первого скролла или любого другого действия на странице пользователь увидит обложку, дизайн этой страницы. Если говорить об экране компьютера или ноутбука, то сделать что-то не так со страницей сложно: нет никаких ограничений, пространства на странице очень много, чтобы сделать и красиво, и удобно. Однако думать только о пользователях, предпочитающих десктопные решения во всех их проявлениях — значит лишать себя большей части аудитории.

Не просто так большая часть трафика проходит именно через мобильные устройства, и кто-то решил эту проблему отдельным приложением, а кто-то предпочитает адаптировать свои продукты под мобильные разрешения в браузере.

Именно про второй вариант мы и поговорим на этот раз.

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

Поговорим о насущном

Озвученная ранее проблема не нова, её можно даже смело сравнить с нерешаемой дилеммой, почему? Сейчас расскажу.

С чего всё начинается

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

И, как говорилось ранее, если интерфейс рассчитан только на десктопные разрешения, то это не большая проблема.

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

В конечном итоге мы получаем дилемму о том, как при таких условиях сохранить функционал, удобство, и чтобы всё не превратилось в кашу.

Но не стоит забывать, что это только часть проблемы, ведь потом всё переходит к frontend-разработчикам.

Все хотят быть программистами

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

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

Как делать не нужно, но многие так делают

Сейчас рассмотрим классический пример оформления страницы, когда никто не задумывается о том, какие задачи могут прийти позже.

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

<p-panel>

  <ng-template pTemplate="header">
    <div class="text24 mb24 text-medium color-grey-13">Личная информация</div>
  </ng-template>


  <ng-template pTemplate="content">
    <form [formGroup]="personalData" class="">

      <div class="p-fluid p-grid">
        <div class="p-field p-col">
          <label>Фамилия</label>
          <div>
            <input type="text" pInputText formControlName="secondName">
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'secondName'}"></ng-container>
<!--            <ng-container *ngIf="getFormField('firstName').touched || getFormField('firstName').dirty">-->
<!--              <div class="text14 color-danger width-100" *ngIf="getFormField('firstName').errors?.required">-->
<!--                Поле должно быть заполнено-->
<!--              </div>-->
<!--            </ng-container>-->
          </div>
        </div>
        <div class="p-field p-col-fixed-500">
          <label>Имя</label>
          <div>
            <input type="text" pInputText formControlName="firstName">
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'firstName'}"></ng-container>
          </div>
        </div>
        <div class="p-field p-col-fixed-500">
          <label>Отчество</label>
          <div>
            <input type="text" pInputText formControlName="patronymic">
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'patronymic'}"></ng-container>
          </div>
        </div>
      </div>

      <div class="p-formgroup-inline">
        <div class="p-field-radiobutton">
          <p-radioButton formControlName="isFullNameChanged" label="Не менял(а)" [value]="false"></p-radioButton>
        </div>
        <div class="p-field-radiobutton">
          <p-radioButton formControlName="isFullNameChanged" label="Менял(а)" [value]="true"></p-radioButton>
        </div>
      </div>

      <div class="p-field width-100" *ngIf="(isChangedFio$ |async) || changeValue">

        <p-panel class="editable-panel" [styleClass]="'bordered-panel'">
          <ng-template pTemplate="header">
            <div class="editable-panel__header text16">
              Укажите все измененные ранее фамилию, имя и отчество, когда и по какой причине меняли
            </div>
          </ng-template>

            <div formArrayName="previousFullNames">
              <div *ngFor="let fio of previousFullNames.controls; let i = index" class="editable-row">
                <div [formGroupName]="i" class="flex ai-center">
                  <div class="width-100" style="flex: 20">
                    <div class="flex width-100 ai-start jc-between">
                      <div class="p-field" style="padding: 0.5em; flex: 1">
                        <label>Фамилия</label>
                        <div>
                          <input class="width-100" type="text" pInputText formControlName="lastName"/>
                          <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'lastName', formArrayItem: fio}"></ng-container>
                        </div>
                      </div>
                      <div class="p-field" style="padding: 0.5em; flex: 1">
                        <label>Имя</label>
                        <div>
                          <input class="width-100" type="text" pInputText formControlName="firstName" />
                          <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'firstName', formArrayItem: fio}"></ng-container>
                        </div>

                      </div>
                      <div class="p-field" style="padding: 0.5em; flex: 1">
                        <label>Отчество</label>
                        <div>
                          <input class="width-100" type="text" pInputText formControlName="patronymic" />
                          <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'patronymic', formArrayItem: fio}"></ng-container>
                        </div>
                      </div>
                    </div>
                    <div>
                      <div class="p-field width-100" style="padding: 0.5em;">
                        <label>Где и по какой причине меняли</label>
                        <div>
                          <textarea class="width-100 pt8 pb8 pl16 pr16"
                                    style="line-height: 20px"
                                    type="text"
                                    formControlName="description"
                                    pInputTextarea
                                    [autoResize]="true"
                                    [rows]="5"
                          ></textarea>
                          <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'description', formArrayItem: fio}"></ng-container>
                        </div>
                      </div>
                    </div>
                  </div>

                  <button pButton
                          style="flex: 1"
                          type="button"
                          class="text18 mt24 color-black"
                          icon="mi mi-trash"
                          (click)="deleteChangedFio(i)"
                          [disabled]="previousFullNames.controls.length === 1"
                  ></button>
                </div>
              </div>
            </div>

          <ng-template pTemplate="footer">
            <div class="inline">
              <button pButton
                      type="button"
                      label="Добавить"
                      class="btn-link-secondary"
                      icon="mi mi-plus"
                      (click)="addChangedFio()"
              ></button>
            </div>
          </ng-template>

        </p-panel>

      </div>

      <div class="p-fluid p-grid">
        <div class="p-field p-col-fixed-400">
          <label>Число, месяц и год рождения</label>
          <div>
            <p-calendar [firstDayOfWeek]="1"
                        [monthNavigator]="true"
                        [showIcon]="true"
                        [yearNavigator]="true"
                        yearRange="1961:2030"
                        dateFormat="dd.mm.yy"
                        appendTo="body"
                        formControlName="dateOfBirth"
            ></p-calendar>
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'dateOfBirth'}"></ng-container>
          </div>
        </div>
      </div>

      <div class="p-fluid p-grid">
        <div class="p-field p-col">
          <label>Место рождения (село, деревня, город, район, область, край, республика)</label>
          <div>
            <input type="text" pInputText formControlName="placeOfBirth">
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'placeOfBirth'}"></ng-container>
          </div>
          <div class="text-muted-light text14">Здесь следует указать конкретное место Вашего рождения по паспорту, например: «г. Железногорск, Курская область»</div>
        </div>
      </div>

      <div class="p-fluid p-grid">
        <div class="p-field p-col">
          <label>Паспорт (серия, номер, кем выдан, код подразделения, дата выдачи)</label>
          <div>
            <textarea type="text" pInputTextarea formControlName="passport"></textarea>
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'passport'}"></ng-container>
          </div>
          <div class="text-muted-light text14">Данные паспорта вносятся в последовательности, указанной в вопросе:
            «серия 45 07 № 259647, выдан ОВД г. Железногорска, код подразделения 770-663, дата выдачи 20.11.1999»
          </div>
        </div>
      </div>

      <div class="p-fluid p-grid">
        <div class="p-field p-col-6">
          <label>Индивидуальный номер налогоплательщика (ИНН)</label>
          <div>
            <p-inputMask type="text" formControlName="inn" mask="9999999999"></p-inputMask>
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'inn'}"></ng-container>
          </div>
        </div>
        <div class="p-field p-col-6"></div>
      </div>

      <div class="p-fluid p-grid">
        <div class="p-field p-col-6">
          <label>Номер телефона для связи</label>
          <div>
            <p-inputMask type="text" formControlName="phone" mask="+7(999) 999-99 99"></p-inputMask>
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'phone'}"></ng-container>
          </div>
        </div>
        <div class="p-field p-col-6">
          <label>Электронная почта </label>
          <div>
            <input type="text" pInputText formControlName="email">
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'email'}"></ng-container>
          </div>
        </div>
      </div>

      <div class="p-fluid p-grid">
        <div class="p-field p-col">
          <label>Данные аккаунтов в социальных сетях</label>
          <div>
            <textarea type="text" pInputTextarea formControlName="socialMedia"></textarea>
          </div>
        </div>
      </div>

    </form>
  </ng-template>

</p-panel>         

Если внимательно посмотреть на разметку, то в глаза бросается как минимум отсутствие общего стиля. Где-то проблема расположения элементов решается путем добавления классов из PrimeNg, где-то маленькие несостыковки убираются добавлением инлайновых стилей, где-то флексы, где-то гриды. Такое часто происходит, когда хочется поскорее закончить работу над этой частью приложения и приступить к созданию логики или когда команда из нескольких человек работает не сообща, не выдерживая общего стиля, если он вообще существует. Но даже написанный таким образом интерфейс будет соответствовать макету и выполнять свои задачи. А если, допустим, придет еще один человек в команду или проект перейдет в новые руки, в какую свалку классов и стилей превратится эта верстка?

Почему многие так делают?

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

Оно ведь ни на что не повлияет: поддерживать на начальных этапах легко, память свежа, да и делать ничего с этой версткой не нужно. Быстро накидали, и готово.

В итоге получается что-то похожее на это:

Для больших разрешений все выглядит даже вполне прилично, никаких недостатков не наблюдается.

Однако все изъяны вскрываются, когда мы уменьшаем ширину страницы. Всё поехало, вылазит за рамки В общем, пользоваться невозможно.

Как делать нужно, но мало кто так делает

Адаптив всему голова

Требования к проектам постоянно меняются, может произойти даже кардинальная смена вектора развития продукта, а такое случается часто. Даже если в требованиях к продукту никогда не появится адаптива, например, если будет создаваться отдельное приложение, верстка — это такая же часть кодовой базы, как и остальная логика по работе с API или же обработке данных. Именно поэтому верстку необходимо держать в едином минималистичном стиле, без избытка классов, без изобилия различных div-элементов по любому поводу. Всегда нужно делать зазор на то, что когда-то добавится адаптив. Именно это понимание и поможет воспитать дисциплину кода на проекте, всегда продумать наперед шаги по реализации интерфейса и задавать себе вопрос: «Если придется сделать это адаптивным, создаст ли моё решение много проблем?»

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

Нужны все разрешения, которые возможны

Теперь обсудим возможность потреблять контент со всех устройств, которые могут оказаться у пользователя. Дизайнерские макеты зачастую выглядят таким образом, что существует вариант дизайна под 2-3 разрешения, а то, что происходит между ними, должно происходить само по себе.

Экраны устройств разнообразны, они не укладываются в те нормы, которые нарисованы на макетах. Но пытаться предвидеть все разрешения с шагом в один пиксель — это тоже не выход. Как уже сказано, это должно происходить само по себе, подстраиваться под ширину экрана, и в моменте выглядеть хорошо. Как раз-таки такой подход реализуем на основании классов и компонентов PrimeNg.

Библиотека PrimeNg

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

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

Помимо этого, библиотека имеет свою сетку из 12 столбцов, использование которой как раз и приведет нас к поставленным целям с минимумом затрат.

Макеты интерфейса

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

Десктопная версия страницы:

Десктопная версия страницы
Десктопная версия страницы

Мобильная версия страницы:

Мобильная версия страницы
Мобильная версия страницы

Приступим к делу

Перейдем непосредственно к технологиям. Про Angular рассказывать не буду, его возможности довольно широко известны, остановимся подробнее на PrimeNg.

Прежде всего необходимо установить библиотеку в проект:

npm install primeflex@2.0.0 --save

А также в файле angular.json в самый конец поля styles добавить строку

   "node_modules/primeflex/primeflex.css"

Чтобы в итоге получилось так:

"styles": [
   "src/styles.scss",
……
//
…….
   "node_modules/primeflex/primeflex.css"
],

Возможности PrimeNg

Эта библиотека представляет собой большой набор компонентов, стилей и классов.

Тэги под компоненты имеют собственные названия, и селектор каждого компонента начинается с ‘p-‘.

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

Допустим, необходимо сделать внешний отступ у элемента справа. Добавим в атрибут class элемента значение p-mr- и укажем значение, на которое необходимо отступить p-mr-16.

А если нужно, чтобы наш контейнер стал флексом, и попутно выровнять всё содержимое посередине? По той же логике добавляем классы p-d-flex (d означает display), p-jc-center, p-ai-between, и готово: контейнер стал флексом и выровнял всё посередине.

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

Теперь ближе к сути.

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

Чтобы элементы на странице или в блоке начали опираться на неё, родительскому элементу или контейнеру необходимо добавить класс p-grid.

В свою очередь, его «детям» необходимо указать, сколько столбцов каждый из них будет занимать. Для этого необходимо прописать класс p-col- и добавить значение, которое будет соответствовать количеству столбцов, занимаемых элементом. Посмотрим, что должно получиться:

<div class="p-grid">
  <div class="p-field p-col-6">
    <label>Номер телефона для связи</label>
    <div>
      <p-inputMask type="text" formControlName="phone" mask="+7(999) 999-99 99"></p-inputMask>
    </div>
  </div>
  <div class="p-field p-col-6">
    <label>Электронная почта </label>
    <div>
      <input type="text" pInputText formControlName="email">
    </div>
  </div>
</div>

Есть контейнер с классом p-grid, а также два поля ввода (кстати, тоже взятые из PrimeNg), которые занимают по половине сетки. Они уже будут выровнены, будут подстраиваться под изменение ширины экрана, и всё это после добавления нужных классов.

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

В сетке PrimeNg существует уже зарезервированные переменные под разную ширину:

  • sm: 576px

  • md: 768px

  • lg: 992px

  • xl: 1200px

Данные размеры перекрывают большинство потребностей, и использовать их легко

<div class="p-grid">
  <div class="p-field p-col-12 p-lg-6">
    <label>Номер телефона для связи</label>
    <div>
      <p-inputMask type="text" formControlName="phone" mask="+7(999) 999-99 99"></p-inputMask>
    </div>
  </div>
  <div class="p-field p-col-12 p-lg-6">
    <label>Электронная почта </label>
    <div>
      <input type="text" pInputText formControlName="email">
    </div>
  </div>
</div>

Тут добавилось всего по одному классу в каждый элемент, однако теперь класс p-lg-6 на больших разрешениях перекрывает класс p-col-12 и получается следующая картина: если при ширине до 992px два инпута занимают всю ширину и располагаются друг под другом, то на разрешениях после 992px они переместятся и будут занимать по половине сетки. Чаще всего необязательно описывать все 4 варианта, а можно указать ширину по умолчанию через p-col- , а затем, когда необходимо перерисовать сетку, указать ширину, после которой будут изменения. В данном случае p-lg- покроет и lg, и xl размерности, а дефолтное значение p-col- покроет sm и md размеры.

 Сетка PrimeNg предоставляет большой спектр инструментов, таких как флексы, гриды, отступы, а также множество тонких настроек, таких как отступы внутри сетки. Если необходимо, чтобы элемент стартовал не на первой колонке, а, допустим, на третьей, то необходимо задать класс p-offset- и указать, сколько строк необходимо отступить от предыдущего элемента или от начала сетки, если элемент первый.

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

<div class="p-fluid p-grid">
  <div class="p-field p-col">
    <label>Место рождения (село, деревня, город, район, область, край, республика)</label>
    <div>
      <input type="text" pInputText formControlName="placeOfBirth">
    </div>
    <div class="text-muted-light text14">Здесь следует указать конкретное место Вашего рождения по паспорту, например: «г. Железногорск, Курская область»</div>
  </div>
</div>

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

<div class="p-grid">
  <div class="p-field p-col-12">
    <label>Место рождения (село, деревня, город, район, область, край, республика)</label>
    <div>
      <input type="text" pInputText formControlName="placeOfBirth">
    </div>
    <div class="p-d-block p-d-lg-none mt8 mb24">
      <button pButton
        type="button"
        label="Подробное описание"
        class="btn-icon text14 text-muted-light flex ai-center"
        icon="{{placeOfBirthToolTipVisible? 'mi mi-caret-up-2' : 'mi mi-caret-down-2'}}"
        iconPos="right"
        (click)="placeOfBirthToolTipVisible = !placeOfBirthToolTipVisible"
       ></button>
      <div *ngIf="placeOfBirthToolTipVisible" class="text-muted-light text14 mt8">
        Здесь следует указать конкретное место Вашего рождения по паспорту, например: «г. Железногорск, Курская область»
      </div>
    </div>
    <div class="text-muted-light text14 p-d-none p-d-lg-block mt8 mb24">
      Здесь следует указать конкретное место Вашего рождения по паспорту, например: «г. Железногорск, Курская область»
    </div>
  </div>
</div>

Итак, добавим элемент с кнопкой, которая по клику будет менять видимость подсказки. Классы p-d-none, p-d-lg-block как раз и отвечают за видимость элемента в зависимости от разрешения. По аналогии с сеткой, тут добавляется в класс сокращенное обозначение разрешения и тип отображения элемента. В примере статичный текст будет отображаться как block элемент на разрешениях после 992px, а когда ширина будет меньше, то его свойство display будет none. Одновременно с этим элемент с кнопкой будет действовать наоборот, до ширины 992px будет отображаться, а после исчезать. Правила точно такие же, как и с сеткой.

На меньших разрешениях весь текст скрывается, и показывается только при необходимости.

Рассмотрим еще один вариант применения данной механики.

Есть поле input, оно однострочное, отвечает за информацию о хобби, и на больших разрешениях оно будет нормально отображаться даже если текста будет много. Но при сужении страницы, всё меньше текста будет отображаться в нем, и придется помучаться, чтобы проверить, что там написано или промотать в конец строки. Чтобы этого избежать на низких разрешениях, заменим input на textarea, в нем много строк, и весь текст будет виден.

<input type="text"
  class="width-100 p-d-none p-d-md-block"
  pInputText
  [formControl]="getFormField(fieldName, formArrayItem)"
/>
<textarea class="width-100 pt8 pb8 pl16 pr16 p-d-block p-d-md-none"
  style="line-height: 20px"
  type="text"
  [formControl]="getFormField(fieldName, formArrayItem)"
  pInputTextarea
  [autoResize]="true"
  [rows]="5"
></textarea>

Если посмотреть на классы, то становится понятно, что до разрешения 768px отражается textarea, а после уже input. Текст не потерян, формой пользоваться удобно. Есть лишь нюанс: если заниматься отладкой адаптива в браузере, то текст, введенный в input, не будет отображаться в textarea, и наоборот. Однако в реальной жизни при заполнении формы на сайте никто не ставит себе цель проверить его на адаптив в браузере. Страница будет открываться либо на устройстве с меньшим разрешением, либо на большом экране.

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

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

Мобильные возможности самих компонентов

PrimeNg располагает не только сеткой и удобными классами для реализации наших целей. Некоторые компоненты, для которых необходимо быть удобными и на мониторе, и на мобилке оптимизированы под использование на мобильных устройствах уже из коробки. Такие компоненты предполагают использование touchUI свойства, которое предоставляет мобильный интерфейс

Рассмотрим на примере календаря:

<p-calendar [firstDayOfWeek]="1"
            [monthNavigator]="true"
            [showIcon]="true"
            [yearNavigator]="true"
            [readonlyInput]="true"
            dataType="string"
            class="p-d-none p-d-md-block"
            yearRange="1961:2030"
            dateFormat="yy-mm-dd"
            appendTo="body"
            [formControl]="getFormField(fieldName)"
></p-calendar>
<p-calendar [firstDayOfWeek]="1"
            [monthNavigator]="true"
            [showIcon]="true"
            [yearNavigator]="true"
            [readonlyInput]="true"
            [touchUI]="true"
            dataType="string"
            class="p-d-block p-d-md-none"
            yearRange="1961:2030"
            dateFormat="yy-mm-dd"
            appendTo="body"
            [formControl]="getFormField(fieldName)"
></p-calendar>

Сразу же применим полученные знания по поводу подмены элементов. Итак, тут два календаря, для мобилки и для монитора, отличаются они свойством touchUI. Для мобилок включено, а для экранов выше среднего отсутствует, так как по умолчанию оно равно false.

Вот так календари выглядят на странице:

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

Мобильная версия рассчитана прежде всего под удобство использования на сенсорном экране: занимает большую часть доступного пространства, располагается посередине страницы

Таких компонентов много, а в компонентах, где не предусмотрен отдельный мобильный интерфейс, например, в панелях и кнопках, это не играет существенной роли.

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

Итоговый вариант готовой страницы

В итоге после всех улучшений верстка будет выглядеть так:

<div *ngIf="!isLouder" class="spinner">
  <p-progressSpinner strokeWidth="4"></p-progressSpinner>
</div>

<p-panel *ngIf="isLouder">
  <ng-template pTemplate="header">
    <div class="page-header text24 mb24 text-medium color-grey-13">Личная информация</div>
  </ng-template>

  <!--  Шаблон для общей валидации на предмет заполненности поля (required)-->
  <ng-template #validationRequired let-fieldName="fieldName" let-formArrayItem="formArrayItem">
    <ng-container *ngIf="getFormField(fieldName, formArrayItem).touched || getFormField(fieldName, formArrayItem).dirty">
      <div class="text14 color-danger width-100 mb8" *ngIf="getFormField(fieldName, formArrayItem).errors?.required">
        Поле должно быть заполнено
      </div>
    </ng-container>
  </ng-template>

  <!--  Шаблон для календаря в двух вариантахЖ мобильном и десктопном-->
  <ng-template #calendar let-fieldName="fieldName">
    <p-calendar [firstDayOfWeek]="1"
                [monthNavigator]="true"
                [showIcon]="true"
                [yearNavigator]="true"
                [readonlyInput]="true"
                dataType="string"
                class="p-d-none p-d-md-block"
                yearRange="1961:2030"
                dateFormat="yy-mm-dd"
                appendTo="body"
                [formControl]="getFormField(fieldName)"
    ></p-calendar>
    <p-calendar [firstDayOfWeek]="1"
                [monthNavigator]="true"
                [showIcon]="true"
                [yearNavigator]="true"
                [readonlyInput]="true"
                [touchUI]="true"
                dataType="string"
                class="p-d-block p-d-md-none"
                yearRange="1961:2030"
                dateFormat="yy-mm-dd"
                appendTo="body"
                [formControl]="getFormField(fieldName)"
    ></p-calendar>
  </ng-template>

  <!--  Шаблон для поля ввода текста в двух вариантахЖ мобильном(textarea) и десктопном(input)-->
  <ng-template #adaptiveInput let-fieldName="fieldName" let-formArrayItem="formArrayItem">
    <input type="text"
           class="width-100 p-d-none p-d-md-block"
           pInputText
           [formControl]="getFormField(fieldName, formArrayItem)"
    />
    <textarea class="width-100 pt8 pb8 pl16 pr16 p-d-block p-d-md-none"
              style="line-height: 20px"
              type="text"
              [formControl]="getFormField(fieldName, formArrayItem)"
              pInputTextarea
              [autoResize]="true"
              [rows]="5"
    ></textarea>
  </ng-template>

  <ng-template pTemplate="content">
    <form [formGroup]="personalData" class="">
      <div class="p-grid">
        <div class="p-field p-col-12 p-lg-4">
          <label>Фамилия</label>
          <div>
            <input type="text" pInputText formControlName="lastName">
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'lastName'}"></ng-container>
          </div>
        </div>
        <div class="p-field p-col-12 p-lg-4">
          <label>Имя</label>
          <div>
            <input type="text" pInputText formControlName="firstName">
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'firstName'}"></ng-container>
          </div>
        </div>
        <div class="p-field p-col-12 p-lg-4">
          <label>Отчество</label>
          <div>
            <input type="text" pInputText formControlName="patronymic">
          </div>
        </div>
      </div>

      <div class="p-field">
        <label>
          Если изменяли фамилию, имя или отчество, то укажите их, а также когда, где и по какой причине
        </label>
        <div class="p-formgroup-inline mt8">
          <div class="p-field-radiobutton">
            <p-radioButton formControlName="isFullNameChanged" label="Не менял(а)" [value]="false"></p-radioButton>
          </div>
          <div class="p-field-radiobutton">
            <p-radioButton formControlName="isFullNameChanged" label="Менял(а)" [value]="true"></p-radioButton>
          </div>
        </div>
      </div>

      <div class="p-field width-100" *ngIf="(isChangedFio$ |async) || changeValue">

        <p-panel class="editable-panel" [styleClass]="'bordered-panel'">
          <ng-template pTemplate="header">
            <div class="editable-panel__header text16">
              Укажите все измененные ранее фамилию, имя и отчество, когда и по какой причине меняли
            </div>
          </ng-template>

            <div formArrayName="previousFullNames">
              <div *ngFor="let fio of previousFullNames.controls; let i = index" class="editable-row">
                <div [formGroupName]="i" class="p-grid p-grid--no-margin">
                  <div class="p-col-12 p-d-flex p-d-lg-none ai-center jc-between editable-row__index">
                    <span>{{i+1}}</span>
                    <button pButton
                            type="button"
                            icon="mi mi-close"
                            (click)="deleteChangedFio(i)"
                            [disabled]="previousFullNames.controls.length === 1"
                    ></button>
                  </div>
                  <div class="p-grid p-col-12 p-lg-11 editable-row__controls">
                    <div class="p-grid p-col-12">
                      <div class="p-field p-col-12 p-lg-4" >
                        <label>Фамилия</label>
                        <div>
                          <input class="width-100" type="text" pInputText formControlName="lastName"/>
                          <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'lastName', formArrayItem: fio}"></ng-container>
                        </div>
                      </div>
                      <div class="p-field p-col-12 p-lg-4">
                        <label>Имя</label>
                        <div>
                          <input class="width-100" type="text" pInputText formControlName="firstName" />
                          <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'firstName', formArrayItem: fio}"></ng-container>
                        </div>
                      </div>
                      <div class="p-field p-col-12 p-lg-4">
                        <label>Отчество</label>
                        <div>
                          <input class="width-100" type="text" pInputText formControlName="patronymic" />
                          <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'patronymic', formArrayItem: fio}"></ng-container>
                        </div>
                      </div>
                    </div>
                    <div class="p-grid p-col-12">
                      <div class="p-field p-col-12">
                        <label>Где и по какой причине меняли</label>
                        <div>
                          <textarea class="width-100 pt8 pb8 pl16 pr16"
                                    style="line-height: 20px"
                                    type="text"
                                    formControlName="description"
                                    pInputTextarea
                                    [autoResize]="true"
                                    [rows]="5"
                          ></textarea>
                          <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'description', formArrayItem: fio}"></ng-container>
                        </div>
                      </div>
                    </div>
                  </div>
                  <button pButton
                          type="button"
                          class="text18 color-black p-d-none p-lg-1 p-d-lg-flex ai-start mt56"
                          icon="mi mi-trash"
                          (click)="deleteChangedFio(i)"
                          [disabled]="previousFullNames.controls.length === 1"
                  ></button>
                </div>
              </div>
            </div>

          <ng-template pTemplate="footer">
            <div class="inline">
              <button pButton
                      type="button"
                      label="Добавить"
                      class="btn-link-secondary"
                      icon="mi mi-plus"
                      [disabled]="previousFullNames.controls.length === config.MAX_LIMIT_SIZE_LIST"
                      (click)="addChangedFio()"
              ></button>
            </div>
          </ng-template>

        </p-panel>

      </div>

      <div class="p-grid">
        <div class="p-field p-col-12 p-lg-4">
          <label>Число, месяц и год рождения</label>
          <div>
            <ng-container [ngTemplateOutlet]="calendar" [ngTemplateOutletContext]="{fieldName: 'dateOfBirth'}"></ng-container>
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'dateOfBirth'}"></ng-container>
          </div>
        </div>
      </div>

      <div class="p-grid">
        <div class="p-field p-col-12">
          <label>Место рождения (село, деревня, город, район, область, край, республика)</label>
          <div>
            <ng-container [ngTemplateOutlet]="adaptiveInput" [ngTemplateOutletContext]="{fieldName: 'placeOfBirth'}"></ng-container>
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'placeOfBirth'}"></ng-container>
          </div>
          <div class="p-d-block p-d-lg-none mt8 mb24">
            <button pButton
                    type="button"
                    label="Подробное описание"
                    class="btn-icon text14 text-muted-light flex ai-center"
                    icon="{{placeOfBirthToolTipVisible? 'mi mi-caret-up-2' : 'mi mi-caret-down-2'}}"
                    iconPos="right"
                    (click)="placeOfBirthToolTipVisible = !placeOfBirthToolTipVisible"
            ></button>
            <div *ngIf="placeOfBirthToolTipVisible" class="text-muted-light text14 mt8">
              Здесь следует указать конкретное место Вашего рождения по паспорту, например: «г. Железногорск, Курская область»
            </div>
          </div>
          <div class="text-muted-light text14 p-d-none p-d-lg-block mt8 mb24">
            Здесь следует указать конкретное место Вашего рождения по паспорту, например: «г. Железногорск, Курская область»
          </div>
        </div>
      </div>

      <div class="p-field width-100">
        <p-panel class="editable-panel" [styleClass]="'bordered-panel'">
          <ng-template pTemplate="header">
            <div class="editable-panel__header text16 text-medium">
              Паспортные данные
            </div>
          </ng-template>

              <div class="p-grid p-grid--no-margin">
                <div class="p-grid p-col-12 editable-row__controls">
                  <div class="p-grid p-col-12">
                    <div class="p-field p-col-4 p-lg-3" >
                      <label>Серия</label>
                      <div>
                        <p-inputMask class="width-100" type="text" formControlName="passportSeries" mask="9999"></p-inputMask>
<!--                        <input class="width-100" type="text" pInputText formControlName="passportSeries"/>-->
                        <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'passportSeries'}"></ng-container>
                      </div>
                    </div>
                    <div class="p-field p-col-8 p-lg-3">
                      <label>Номер</label>
                      <div>
                        <p-inputMask class="width-100" type="text" formControlName="passportNumber" mask="999999"></p-inputMask>
<!--                        <input class="width-100" type="text" pInputText formControlName="passportNumber" />-->
                        <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'passportNumber'}"></ng-container>
                      </div>
                    </div>
                    <div class="p-field p-col-12 p-lg-3">
                      <label>Дата выдачи</label>
                      <div>
                        <ng-container [ngTemplateOutlet]="calendar" [ngTemplateOutletContext]="{fieldName: 'passportDateOfIssue'}"></ng-container>
                        <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'passportDateOfIssue'}"></ng-container>
                      </div>
                    </div>
                    <div class="p-field p-col-12 p-lg-3">
                      <label>Код подразделения</label>
                      <div>
                        <p-inputMask class="width-100" type="text" formControlName="passportUnitCode" mask="999-999"></p-inputMask>
<!--                        <input class="width-100" type="text" pInputText formControlName="passportUnitCode" />-->
                        <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'passportUnitCode'}"></ng-container>
                      </div>
                    </div>
                  </div>
                  <div class="p-grid p-col-12">
                    <div class="p-field p-col-12">
                      <label>Кем выдан</label>
                      <div>
                        <input class="width-100" type="text" pInputText formControlName="passportWhoIssued" />
                        <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'passportWhoIssued'}"></ng-container>
                      </div>
                    </div>
                  </div>
                </div>
              </div>
        </p-panel>
      </div>

      <div class="p-grid">
        <div class="p-field p-col-12 p-lg-6">
          <label>Индивидуальный номер налогоплательщика (ИНН)</label>
          <div>
            <p-inputMask type="text" formControlName="inn" mask="9999999999"></p-inputMask>
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'inn'}"></ng-container>
          </div>
        </div>
      </div>

      <div class="p-grid">
        <div class="p-field p-col-12 p-lg-6">
          <label>Номер телефона для связи</label>
          <div>
            <p-inputMask type="text" formControlName="phone" mask="+7(999) 999-99 99"></p-inputMask>
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'phone'}"></ng-container>
          </div>
        </div>
        <div class="p-field p-col-12 p-lg-6">
          <label>Электронная почта </label>
          <div>
            <input type="text" pInputText formControlName="email">
            <ng-container [ngTemplateOutlet]="validationRequired" [ngTemplateOutletContext]="{fieldName: 'email'}"></ng-container>
          </div>
        </div>
      </div>

      <div class="p-grid">
        <div class="p-field p-col">
          <label>Данные аккаунтов в социальных сетях</label>
          <div>
            <textarea type="text" pInputTextarea formControlName="socialMedia"></textarea>
          </div>
        </div>
      </div>

    </form>
  </ng-template>

</p-panel>

Немного оптимизации

Если присмотреться к началу кода, то можно заметить несколько блоков ng-template, а в самом коде ng-container с их шаблонными переменными. Для оптимизации все парные элементы, которые друг друга заменяют, вынесены в шаблоны, и используются в нескольких местах, так же было сделано и с валидационными сообщениями.

  <ng-template #adaptiveInput let-fieldName="fieldName" let-formArrayItem="formArrayItem">
    <input type="text"
           class="width-100 p-d-none p-d-md-block"
           pInputText
           [formControl]="getFormField(fieldName, formArrayItem)"
    />
    <textarea class="width-100 pt8 pb8 pl16 pr16 p-d-block p-d-md-none"
              style="line-height: 20px"
              type="text"
              [formControl]="getFormField(fieldName, formArrayItem)"
              pInputTextarea
              [autoResize]="true"
              [rows]="5"
    ></textarea>
  </ng-template>

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

<ng-container 
  [ngTemplateOutlet]="adaptiveInput" 
  [ngTemplateOutletContext]="{fieldName: 'placeOfBirth'}"
></ng-container>

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

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

Страница приобрела следующий вид:

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

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

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

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

Комментарии (3)


  1. yuriy-bezrukov
    01.08.2023 11:55

    Слишком много кода (кстати, было бы неплохо отправить его сначала на ревью)

    В основном статья про бутстрап лейаут...


  1. SiSya
    01.08.2023 11:55

    Во-первых, такие вещи лучше делать вне скоупа Angular, раз уж заголовок об адаптиве.

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

    В-третьих, на сегодняшний день, подобные бутстрапы и MUI уходят в прошлое, как мне кажется. Людям надоело тащить это в прод. Моветон такое популяризовать, если вы уважаете начинающих разработчиков.


  1. ED4M
    01.08.2023 11:55

    Заголовок явно вводит в заблуждение. Всю статью можно пересказать как «Для адаптивной вёрстки подключите primeNg и делайте всё как в документации».