Режимы и состояния в SCSS

Соображения на тему использования режимов и состояний компонентов пользовательского интерфейса.

Рассмотрим применение подхода при работе с препроцессорами стилей⁠. Осторожно, в статье слишком много примеров кода⁠.

⁠, опубликовано на habr.ru ⁠.

Концепция

Компонент интерфейса может иметь несколько состояний: зелёная кнопка может быть нажата и отжата⁠. Компонент интерфейса может иметь несколько наборов состояний: кнопка может быть не только зелёная, но и голубая⁠, и обе они могут быть как нажаты⁠, так и отжаты⁠.

Каждому состоянию кнопки соответствует некоторое оформление. Оформление компонента интерфейса может повторяться в различных состояниях одного режима и в разных режимах⁠. Количество состояний режима зависит от конкретного режима⁠.

Соотношение Режим-Состояние
Соотношение Режим-Состояние

Таким образом⁠, можно условиться⁠, что

View⁠ / Представление
набор стилей характеризующих сущность пользовательского интерфейса (⁠в конкретном Состоянии);
State⁠ / Состояние
условия влияющие на Представление⁠;
Mode⁠ / Режим
множество Состояний одной сущности.

В роли объекта Режима выступает компонент интерфейса не ниже уровня Блок в терминах БЭМ⁠.

Два режима не могут иметь Представления с одинаковыми свойствами. Если Режимы влияют на одни и те же свойства⁠, объедините их⁠.

Пример простой

В интерфейсе кнопка имеет несколько состояний: link⁠ — в ожидании действия пользователя, hover⁠ — пользователь навёл указатель мыши на кнопку⁠, active⁠ — пользователь зажал левую клавишу мыши над кнопкой⁠. Кнопка может быть синей и зелёной⁠.

Пример с кнопками
Пример с кнопками

В данном примере обе кнопки (голубая и зелёная⁠) представлены в трёх состояниях. Для каждой из кнопок набор состояний идентичен. Набор состояний образует некоторый режим⁠.

Режимы и состояния на примере кнопок
Режимы и состояния на примере кнопок

Я формирую стили для кнопок следующим образом.

// set modes
@each $skinName, $skinColor in (
    ( 'green',   $green ), 
    ( 'blue',    $blue )
) {

    // set state «link»
    .button_color_#{ $skinName } {
        // call view
        @include skinView($skinName, 'link', $skinColor);
    }

    // set state «hover»
    .button_color_#{ $skinName }:hover:not(.button_disabled) {
        // call view
        @include skinView($skinName, 'hover', $skinColor);
    }

    // set state «active»
    .button_color_#{ $skinName }:active:not(.button_disabled) {
        // call view
        @include skinView($skinName, 'active', $skinColor);
    }
}

Описание Представлений отделено от описания Состояний:

// set views
@mixin skinView($skin, $state, $color) {
    & {
        background-color:
            if( $state == 'link',   $color,               null )
            if( $state == 'hover',  lighten($color, 10%), null )
            if( $state == 'active', darken($color,  10%), null );
    }
    
    &:before {
        border-bottom-color:
            if( $state == 'link',   darken($color, 10%), null )
            if( $state == 'hover',  $color,              null )
            if( $state == 'active', darken($color, 20%), null );
    }

    .button__text {
        color:
            if( $state == 'link',   #fff,                 null )
            if( $state == 'hover',  lighten($color, 45%), null )
            if( $state == 'active', lighten($color, 40%), null );
    }
}

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

Пример посложнее

На данном сайте ширина контентной области не фиксирована⁠. Контентную часть наполняет множество блоков⁠: параграфы, списки, изображения, примеры кода и т.д. Каждый из контентных блоков имеет несколько модификаций по различным параметрам. Например, списки могут быть <ol>⁠, <ul> и <dl>⁠.

И каждый из них может быть как «широким»⁠, так и «узким» в зависимости от различных условий⁠.

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

Различное поведение ширины контентной области
Различное поведение ширины контентной области

Если экран пользователя, относительно, большой, то список занимает ровно 640px по ширине⁠, если экран маленький, — всю ширину контентной области страницы⁠.

Схожая зависимость присуща и другим контентным блокам⁠. Контентная область сниппета кода на большом экране узкая⁠, его ширина составляет 640px⁠, а на маленьком⁠ — узкая, но другая⁠.

А, «широкое» изображение⁠, широкое на всех экранах — оно занимает 100% ширины родителя⁠. Таким образом комбинаций Представлений контентных блоков слишком большое⁠.

Большое количество контентных блоков и наличие нескольких условий отображения заставляют создать одну универсальную абстрактную сущность, поведение / характеристики которой наследовали бы все контентные блоки⁠.

На моём сайте каждый контентный блок принимает один из трёх Режимов поведения:

wide⁠ / широкий
широкий на большом экране, широкий на маленьком⁠;
tricky⁠ / хитрый
узкий на большом экране, широкий на маленьком⁠;
slim⁠ / узкий
узкий на большом экране, узкий на маленком⁠.

Условиями отображения являются: тип устройства и размер экрана⁠. Сложности добавляет Режим «Хитрый»⁠, который включает Представления от двух других режимов: на большом экране он «узкий»⁠, а на маленьком⁠ — «широкий»⁠.

Контентные блоки различной ширины
Контентные блоки различной ширины

Режимы и Cостояния описываются следующим образом:

// set modes
@mixin slimContent($mode) {

    // set states for desktop and tablet
    @include device(desktop, tablet) {
        @include screen_m-- {
            // call view
            @include slimContent__view($mode, 'screenBig');
        }
        
        @include screen_--s {
            // call view
            @include slimContent__view($mode, 'screenSmall');
        }
    }

    // set state for phone
    @include device(phone) {
        // call view
        @include slimContent__view($mode, 'screenSmall');
    }
}

Для описания Представлений используем хелпер⁠:

// set views
@mixin slimContent__view($mode, $screenSize) {
    @if $mode == 'wide' {
        @if $screenSize == 'screenBig' {
            // call view-helper
            @include slimContent__view-helper(padding, 0);
        }

        @if $screenSize == 'screenSmall' {
            // call view-helper
            @include slimContent__view-helper(padding, 0);
        }
    }

    @if $mode == 'tricky' {
        @if $screenSize == 'screenBig' {
            // call view-helper
            @include slimContent__view-helper(margin, auto);
        }

        @if $screenSize == 'screenSmall' {
            // call view-helper
            @include slimContent__view-helper(margin, 0);
        }
    }

    @if $mode == 'slim' {
        @if $screenSize == 'screenBig' {
            // call view-helper
            @include slimContent__view-helper(padding, auto);
        }

        @if $screenSize == 'screenSmall' {
            // call view-helper
            @include slimContent__view-helper(padding, $d);
        }
    }
}

Содержание хелпера таково⁠:

// view-helper
@mixin slimContent__view-helper($property, $value) {
    @include device(desktop, tablet, phone) {
        
        @if $value == 'auto' {
            #{$property}-left: calc((100% - #{$contentWidth})*(1/3));
            #{$property}-right: calc((100% - #{$contentWidth})*(2/3));
        }

        @else {
            @if $value != 0 {
                #{$property}-left: $value;
                #{$property}-right: $value;
            }
        }
    }

    @include device(ie) {
        @if $value == 'auto' {
            #{$property}-left: 3*$d;
            #{$property}-right: 6*$d;
        }

        @else {
            @if $value != 0 {
                #{$property}-left: $value;
                #{$property}-right: $value;
            }
        }
    }
}

Применение Режима элементарно:

.ui-snippet {
    @include slimContent(slim);
    @include ritm(1*$v);
    overflow-x: auto;
}

.ui-image {
    &.ui-image_wide {
        @include slimContent(wide);
    }

    &.ui-image_narrow {
        @include slimContent(tricky);
    }
}

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