Div на div’е не сидит и div’ом не погоняет: пишем семантически верные индикаторы загрузки на Angular

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

Однако эта простота обманчива. Интернет наполнен множеством решений, в которых индикатор загрузки анатомически состоит из кучи вложенных друг в друга div-контейнеров, приправленных щепоткой CSS. Не нужно так! В мире грустит один котенок, когда вы игнорируете семантику верстки и забываете про доступность (a11y).

В этой статье я расскажу, как мы в проекте Taiga UI подошли к написанию собственных Angular-компонентов ProgressBar и ProgressCircle.

image-loader.svg

Пишем свой ProgressBar

Формулируем задачу. Есть встроенный HTML-тег . Как говорит MDN:

HTML-элемент отображает индикатор, показывающий ход выполнения задачи, обычно отображаемый в виде прогресс-бара (индикатора выполнения).

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

Неправильное решение —идти за первой очевидной мыслью: «Давайте не будем использовать встроенный HTML-тег: он некрасивый и по-разному отрисовывается в каждом браузере». И тут мы пополняем интернет еще одним неверным решением. Делаем два -контейнера: один для дорожки/колеи (track), а другой — для самого индикатора. После кастомизируем эти контейнеры и управляем через JS значением width у контейнера-индикатора (от 0 до 100%).

Упрощенный вариант такого решения выглядит следующим образом. В HTML-файле будут следующие два контейнера:

А в CSS-файле:

.track {
	position: relative;
	background-color: grey;
    
	width: 300px;
	height: 20px;
}

.indicator {
	position: absolute;
	top: 0;
	left: 0;
	height: 100%;
	width: 50%; /* change it via JS */
	background-color: yellow;
}

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

В таких случаях люди обычно используют считывающие экран устройства (screen reader). Эти устройства озвучивают пользователю все важное, что происходит на экране. Но в нашем случае устройство не сможет понять, что перед нами индикатор загрузки: оно лишь увидит два окрашенных в разный цвет контейнера и даже не станет это озвучивать.

Предложенное решение нарушает не только семантику верстки, но и вредит доступности (accessibility) нашего веб-приложения. Мы можем подсказать скринридеру, что перед ним индикатор загрузки, установив на данном решении role=«progressbar», а также задав значения aria-valuenow, aria-valuemax и aria-valuemin. Но в таком случае мы сильно усложняем то, что можно сделать проще.

Улучшаем неверное решение. Чтобы восстановить доступность предыдущего решения, существует популярная практика. Мы можем вложить в наш индикатор загрузки визуально скрытый нативный . Передавать в этот тег нужные ему свойства (value и max), а все остальное оставить как и было в прошлом решении.

Есть разные способы визуально скрыть HTML-контейнер, но оставить его видимым для устройств, считывающих экран. Обычно это класс sr-only. Например, такие классы есть у популярных библиотек Bootstrap и Tailwind CSS. 

Предостережение: не используйте display: none, height: 0 или width: 0. Они скроют контент не только визуально, но и для устройств, считывающих экран. Подробности об этом можно почитать в статье CSS in Action: Invisible Content Just for Screen Reader Users.

Теперь индикатор загрузки не только визуально красивый, но и не нарушает доступность сайта. Например, встроенный в Mac OS Screen Reader озвучивает его как «n процентов индикатор выполнения» (где n = 100 × value / max).

Но семантика верстки нас огорчает! Более того, мы теперь должны дублировать атрибуты элемента как Input()-property-компонента, чтобы он «доставил» их без изменений до нативного элемента. Пока это лишь value и max. А уже завтра вам нужно будет добавить id или одно из свойств data-*. И вот ваш компонент «потяжелел» от ненужных ему свойств.

Рассмотрим наше решение как Angular attribute component, которое позволяет исключить лишнюю верстку. Нам достаточно расширить поведение нативного -элемента. Когда мы расширяем поведение нативного элемента, официальная документация Angular советует создавать компонент, который содержит атрибутный селектор с данным нативным элементом. Такая практика активно используется и в нашей UI-Kit-библиотеке Taiga: например, компонент кнопки, компонент ссылки, Label-компонент и другие.

Создадим typescript-файл с содержимым нашего компонента:

import {
  ChangeDetectionStrategy,
  Component,
  HostBinding,
  Input
} from '@angular/core';

@Component({
  selector: 'progress[tuiProgressBar]',
  template: '',
  styleUrls: ['./progress-bar.component.less'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TuiProgressBarComponent {
  @Input()
  @HostBinding('style.--tui-progress-color')
  color?: string;
}

Изучим содержимое его less-файла. Он состоит из несколько less-миксинов.

Следующий миксин сбрасывает всю «природную» кастомизацию от браузера:

.clearProgress() {
    -webkit-appearance: none;
    -moz-appearance: none;
    appearance: none;
    border: none;
}

А этот позволяет управлять дорожкой индикатора:

.progressTrack(@property, @value) {
    @{property}: @value; // Edge | Mozilla

    &::-webkit-progress-bar {
        @{property}: @value; // Chrome | Opera | Safari
    }
}

Наконец, создаем миксин для кастомизации цвета самого индикатора загрузки:

.progressIndicatorColor(@color) {
    color: @color; // Not Chromium Edge

    &::-webkit-progress-value {
        background: @color; // Chromium Edge | Chrome | Opera | Safari
    }

    &::-moz-progress-bar {
        background: @color; // Mozilla
    }
}

И вешаем полученные миксины на :host-элемент нашего компонента (напоминаю, что в данном случае это нативный -элемент, на который повешен наш атрибутный компонент):

:host {
    .clearProgress();
    .progressIndicatorColor(var(--tui-progress-color, currentColor));
    .progressTrack(background-color, grey);

    color: yellow;
}

Все. Мы получили удобный Angular-атрибутный компонент, обернутый вокруг нативного -элемента. Цвет этого индикатора можно установить через CSS-свойство color, а можно и через input-property компонента (например, если хотим создать градиентный цвет). В коде компонент вызывается следующим образом:

Посмотреть компонент в действии можно на витрине нашего проекта Taiga UI. А финальный исходный код доступен на GitHub.

Пишем свой ProgressCircle

Создать ProgressCircle-компонент, используя возможности только нативного , уже не получится. Придется прибегнуть к дополнительной верстке.

Однако и здесь в интернете находится множество ошибочных решений, когда круговой индикатор загрузки пользователи создают из div-контейнеров, которые подверглись скруглению через border-radius: 50%. Также в таких решениях используется значительное количество JavaScript.

В нашем решении мы будем использовать семантически верный svg-тег . Также обойдемся минимальной помощью JavaScript, будем использовать по максимуму возможности препроцессора less и CSS-переменных.

Создаем typescript-файл нашего будущего компонента:

import {
    ChangeDetectionStrategy,
    Component,
    HostBinding,
    Input,
} from '@angular/core';

@Component({
    selector: 'tui-progress-circle',
    templateUrl: './progress-circle.template.html',
    styleUrls: ['./progress-circle.style.less'],
    changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TuiProgressCircleComponent {
    @Input()
    value = 0;

    @Input()
    max = 1;

    @Input()
    @HostBinding('style.--tui-progress-color')
    color: string | null = null;

    @Input()
    @HostBinding('attr.data-size')
    size: 'm' | 'l' = 'm';

    @HostBinding('style.--progress-percentage')
    get progressPercentage(): number {
        return this.value / this.max;
    }
}

Верстка элемента будет следующая:



А теперь less-файл. Стоит отметить, что здесь используются все возможности less-миксинов, Maps, а также встроенных в less математических функций.

Сначала создадим набор констант-maps, в которых будем хранить значения для разных размеров круговых индикаторов (в нашем проекте их 4, но для простоты кода оставим только 2). Стоит добавить замечание, что Safari не поддерживает единицы rem внутри svg-элементов, но поддерживает em. Поэтому на host-элемент мы навешиваем font-size: 1rem, а дальше в измерениях используем em.

@width: {
    @m: 2em;
    @l: 7em;
};

@track-stroke: {
    @m: 0.5em;
    @l: 0.25em;
};

@progress-stroke: {
    @m: 0.5em;
    @l: 0.375em;
};

Процесс заполнения индикатора происходит за счет значений stroke-dasharray и пересчета значения stroke-dashoffset. Как они работают, читайте в статье Building a Progress Ring, Quickly. Наше решение является улучшенной версией того, что было предложено ее автором. Создаем миксин, отвечающий за пересчет заполненности индикатора при разных размерах компонента:

.circle-params(@size) {
    width: @width[ @@size];
    height: @width[ @@size];

    .track {
        r: (@width[ @@size] - @track-stroke[ @@size]) / 2;
        stroke-width: @track-stroke[ @@size];
    }

    .progress {
        @radius: (@width[ @@size] - @progress-stroke[ @@size]) / 2;
        @circumference: 2 * pi() * @radius;

        r: @radius;
        stroke-width: @progress-stroke[ @@size];
        stroke-dasharray: @circumference;
        stroke-dashoffset: calc(@circumference - var(--progress-percentage) * @circumference);
    }
}

Используем полученный миксин на host-элементе и добавляем косметических улучшений:

:host {
    display: block;
    position: relative;
    color: yellow;
    transform: rotate(-90deg);
    transform-origin: center;
    font-size: 1rem;

    &[data-size='m'] {
        .circle-params(m);
    }

    &[data-size='l'] {
        .circle-params(l);
    }
}

.track {
    fill: transparent;
    stroke: grey;
}

.progress {
    fill: transparent;
    stroke: var(--tui-progress-color, currentColor);
    transition: stroke-dashoffset 300 linear;
}

.hidden-progress {
    .sr-only(); // смотри этот миксин в главе про ProgressBar
}

.svg {
    overflow: unset;
}

Готово! Посмотреть, что у нас получилось, можно на витрине нашего проекта Taiga UI. А финальный исходный код компонента можно изучить в нашем репозитории.

Вместо заключения

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

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

© Habrahabr.ru