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

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




Концепция


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

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

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


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

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


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

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



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


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

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


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

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


Я формирую стили для кнопок следующим образом:
// set modes
@each $skinName, $skinColor in (
	( 'green',   $green ), 
	( 'blue',    $blue )
	) {

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

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

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

Описание Представлений отделено от описания Состояний:
// 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-- {
			@include slimContent__view($mode, 'screenBig'); // call view
			}
		
		@include screen_--s {
			@include slimContent__view($mode, 'screenSmall'); // call view
			}
		}

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


Для описания Представлений используем хелпер:
// set views
@mixin slimContent__view($mode, $screenSize) {
	@if $mode == 'wide' {
		@if $screenSize == 'screenBig' {
			@include slimContent__view-helper(padding, 0); // call view-helper
			}

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

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

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

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

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


Содержание хелпера таково:
// 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);
		}
	}


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

По просьбе stardust_kid размещаю сгенерированный CSS, код для типа устройства desktop. По пути разметил полный код хелпера slimContent__view-helper, с таргетингом по устройствам и проверкой на ноль.

Код для конкретной кнопки
.btn_color_blue {
  background-color: #1aa2e6;
}
.btn_color_blue:before {
  border-bottom-color: #1481b8;
}
.btn_color_blue .btn__text {
  color: #fff;
}

.btn_color_blue:hover:not(.btn_disabled) {
  background-color: #47b4eb;
}
.btn_color_blue:hover:not(.btn_disabled):before {
  border-bottom-color: #1aa2e6;
}
.btn_color_blue:hover:not(.btn_disabled) .btn__text {
  color: #e8f6fc;
}

.btn_color_blue:active:not(.btn_disabled) {
  background-color: #1481b8;
}
.btn_color_blue:active:not(.btn_disabled):before {
  border-bottom-color: #0f618a;
}
.btn_color_blue:active:not(.btn_disabled) .btn__text {
  color: #d1ecfa;
}


Код для сниппета и изображения
@media screen and (min-width: 1240px) {
  pre.ui-code {
    padding-left: calc((100% - 640px)*(1/3));
    padding-right: calc((100% - 640px)*(2/3));
  }
}
@media screen and (max-width: 1239px) {
  pre.ui-code {
    padding-left: 24px;
    padding-right: 24px;
  }
}
// селектор по тегу используется, так как существует инлайновый span.ui-code

@media screen and (min-width: 1240px) {
  .ui-img.ui-img_narrow {
    margin-left: calc((100% - 640px)*(1/3));
    margin-right: calc((100% - 640px)*(2/3));
  }
}



Для понимания масштаба решаемого вопроса приведу некоторые данные:
  • устройств 4
  • контентных блоков около 20
  • каждый контентный блок «внутри» конкретного устройства имеет 1–2 состояния


Оригинал статьи

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


  1. Drag13
    11.12.2015 23:03
    +9

    Где я еще не искал логику? А, да! В CSS! :D


  1. Eklykti
    12.12.2015 03:04
    +4

    Зато многократное дублирование окажется в сгенеренном CSS.


    1. apian
      12.12.2015 05:22

      Будьте добры, поясните.


    1. k12th
      13.12.2015 00:14

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


      1. apian
        13.12.2015 08:39

        Вот я и спрашивал товарища выше по ветке Eklykti, мол что он имеет в виду: дублирование селекторов в одном правиле, дублирование правил, дублирование содержимого правил. Товарищ поступил по-английски.

        В случае с кнопкой код генерируется следующего содержания

        .button__blue    { style for button__blue(bg ,color, border) }
        .button__purple { style for button__purple(bg ,color, border) }
        // дублирования нет
        


        В случае с контентным блоком происходит дублирование правил, т.е.
        @media screen and (min-width: 1240px) {
          pre.ui-code {
            padding-left: calc((100% - 640px)*(1/3));
            padding-right: calc((100% - 640px)*(2/3));
          }
        }
        
        // содержимое правила для сниппета «дублирует» содержимое правила для, скажем, изображения
        @media screen and (min-width: 1240px) {
          .ui-img.ui-img_narrow {
            margin-left: calc((100% - 640px)*(1/3));
            margin-right: calc((100% - 640px)*(2/3));
          }
        }
        

        Не, ну можно, конечно заморочиться, написать пару функций на SassScript, которые бы объеденили группу правил в одно, но зачем, если это задача постпроцессоров. В моём случае пусть не идеально, но кое-где ClosureCompiler генерирует конструкции
        @media screen and (min-width:1240px){.cs,.d1,.ce{margin-left:calc((100% - 640px)*(1/3));margin-right:calc((100% - 640px)*(2/3))}}
        // код после обфускатора
        


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


        1. apian
          13.12.2015 08:45

          не ClosureCompiler, конечно, а CSSO


        1. k12th
          13.12.2015 16:43

          Ну вот и я о том же.


        1. Eklykti
          14.12.2015 12:47

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


          1. apian
            14.12.2015 13:36

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


  1. stardust_kid
    12.12.2015 03:23

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


    1. apian
      12.12.2015 06:28
      +1

      stardust_kid, добавил примеры генерируемого кода в статью. Благодарю за совет.


  1. scrutari
    12.12.2015 10:46

    Спасибо, интересный подход.


  1. guyfawkes
    12.12.2015 13:07

    Подскажите для незнающих SCSS: я правильно понимаю, что $contentWidth задается где-то во внешнем коде в значение 640?


    1. apian
      12.12.2015 14:01

      Да, всё верно, это глобальная переменная проекта.


      1. SerDIDG
        13.12.2015 07:37

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