Переключатель темы

Vlad Poe
Web Standards
Published in
9 min readOct 2, 2017

--

Перевод «A Theme Switcher» Хейдона Пикеринга.

В разработке веб-интерфейсов моя мантра звучит так: «не получается сделать эффективно, не делай вовсе». Это я проповедовал в Европе, Англии, Китае — пишите меньше чертова кода. Если нововведение влечет снижение производительности, совокупный эффект от него будет отрицательным, и от такого стоит отказаться вовсе. Настолько скорость критически важна для веба.

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

Как правило, темы реализовывают через альтернативные таблицы стилей, переключение на которые происходит с помощью JavaScript. В некоторых случаях такой подход сомнителен с точки зрения эффективности (нужно загружать много дополнительного CSS-кода), в других—ставит вопрос поддержки (альтернативный CSS нуждается в изменении вместе с основным при развитии проекта).

Один из действительно полезных типов тем — менее яркий «ночной» режим. Он не просто облегчает нагрузку на глаза при чтении в темноте, но и снижает вероятность мигрени и других расстройств, связанных с чувствительностью к свету. Я сам страдаю от мигрени, это действительно важно!

В статье я расскажу, как создать эффективный компонент для React, позволяющий менять стандартную светлую тему интерфейса на «ночную» и сохраняющий настройки с помощью API localStorage.

Итак, в нашем распоряжении светлая тема (преимущественно — темный текст на светлом фоне). Лучшая стратегия в этом случае — не использовать отдельную таблице стилей, а дополнить существующую. И это дополнение должно быть максимально кратким. К счастью, есть свойство filter, инвертирующее цвета. Оно обычно ассоциируется с изображениями, хотя на самом деле применимо к любому элементу, в то числе <html>.

:root {
filter: invert(100%);
}

Заметьте: не все браузеры поддерживают краткую запись invert(). Для лучшей поддержки не забывайте указать параметр 100%.

C filter одна проблема: он инвертирует только явно указанные цвета. Поэтому, если элемент не имеет фонового цвета, то цвет текста изменится, а вот фон останется прежним (белым). Результат? Светлый текст на белом фоне.

Это легко исправить, указав background-color.

:root {
background-color: #fefefe;
filter: invert(100%);
}

Однако проблема может повториться с дочерними элементами, также не имеющими фона. В этом случае на помощь приходит значение inherit.

:root {
background-color: #fefefe;
filter: invert(100%);
}
* {
background-color: inherit;
}

На первый взгляд, это неаккуратное решение, но причин для беспокойства нет: селектор * имеет очень низкую специфичность, а значит свойство сработает только для элементов без background-color. По сути, #fefefe для них всего лишь фолбек.

Не трогаем растровые картинки

Изменяя тему, мы, скорее всего, хотели бы сохранить растровые изображения и видео, чтобы не испортить дизайн страшными картинками-негативами. Здесь нас выручит двойная инверсия <img>. Я использую селектор, исключающий из выборки SVG-изображения, ведь они, как правило, используются для плоских цветных диаграмм и отлично инвертируются.

:root { 
background-color: #fefefe;
filter: invert(100%);
}
* {
background-color: inherit;
}
img:not([src*=".svg"]), video {
filter: invert(100%);
}

При весе в 153 байта (без сжатия), наша ночная тема в общем-то делает всё, что нужно. Для сомневающихся — подборка того, как приём работает на популярных новостных сайтах.

Компонент для переключения тем

Так как изменение светлой темы (по умолчанию) на темную (инверсную) требует не больше, чем простого переключателя, можно использовать кнопку-переключатель, описанную в предыдущей статье. Только на этот раз, мы сделаем из него компонент для React. И вот почему:

  • Максимальная переиспользуемость. Компонент подойдет любому из ваших проектов на React.
  • Преимущества использования props и defaultProps.
  • Бытует мнение, что фреймворки, подобные React и Angular, не совместимы с доступным HTML. Пора развенчать это заблуждение.

Кроме того, мы будем придерживаться принципа прогрессивного улучшения и проследим, чтобы компонент применялся только для браузеров с поддержкой filter: invert(100%).

Подготовка

Если у вас нет своего проекта на React, можно без лишних хлопот создать новый с помощью create-react-app.

npm i -g create-react-app  
create-react-app theme-switch
cd theme-switch
npm start

Шаблонное приложение запускается на localhost:3000. Назовём наш компонент ThemeSwitch, и подключим его внутри функции render в App.js как <ThemeSwitch></ThemeSwitch>.

class App extends Component {  
render() {
return (
<div className="App">
<div className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<h2>Welcome to React</h2>
</div>
<p className="App-intro">
To get started, edit {gfm-js-extract-pre-1} and save to reload.
</p>
<ThemeSwitch></ThemeSwitch>
</div>
);
}
}

Обратите внимание: я человек ленивый и не менял изначальный шаблон. Но, если вы действительно хотите почувствовать удобство компонента, подключите его внутри страницы со стилями, взятой, например, из другого проекта. Стили можно добавить в App.css.

И не забывайте об импортировать ThemeSwitch в начало файла App.js.

import ThemeSwitch from './components/ThemeSwitch.js'

Каркас файла компонента

Как можно было заметить по строке предыдущего примера кода, наш рабочий файл — ThemeSwitch.js. Он расположен в папке components, поэтому для начала нужно создать и папку и файл. Каркас компонента будет выглядеть так:

import React, { Component } from 'react';class ThemeSwitch extends Component {  
render() {
// Разметка компонента в JSX
}
}
export default ThemeSwitch;

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

<div>  
<button aria-pressed="false">
тёмная тема:
<span aria-hidden="true">выкл</span>
</button>
<style media="none">
html { filter: invert(100%); background: #fefefe }
* { background-color: inherit }
img:not([src*=".svg"]), video { filter: invert(100%) }
</style>
</div>
  • Реализовать переключатель можно по-разному. Мы используем атрибут aria-pressed: он сделает состояние компонента доступным, тогда как стандартное вкл/выкл будет работать для зрячих пользователей. Атрибут aria-hidden нужен, чтобы указатель вкл/выкл не вводил в заблуждение пользователей скринридеров: текст не будет зачитываться. Вместо него они услышат что-то вроде: «Переключатель на темную тему, активный» и «Переключатель на темную тему, неактивный».
  • Наш CSS настолько мал, что мы его можем легко заинлайнить. Установим таблице стилей атрибут media="none" или media="screen" для ативной тёмной темы.

Скоро эта разметка станет пострашнее, ведь мы преобразуем её в JSX.

Переключаем состояние

Наш компонент имеет состояние, которое и позволит управлять активностью или неактивностью темы. Сразу инициализируем его в конструкторе компонента:

constructor(props) {  
super(props);
this.state = {
active: 'false'
};
}

Оживят компонент две функции: функция-помощник isActive() и функция-переключатель toggle():

isActive = () => this.state.active === 'true';  toggle = () => {
this.setState({
active: this.isActive() ? 'false' : 'true'
});
}

Обратите внимание: стрелочные функции возвращают результат выражения неявно, поэтому функция isActive() такая краткая.

Её же используем внутри функции рендера компонента для переключения значения aria-pressed, текста кнопки и атрибута media таблицы стилей:

return (  
<div>
<button aria-pressed={this.isActive() ? 'true' : 'false'} onClick={this.toggle}>
тёмная тема:
<span aria-hidden="true">{this.isActive() ? 'вкл' : 'выкл'}</span>
</Button>
<style media={this.isActive() ? 'screen' : 'none'}>
{this.css}
</style>
</div>
);

Обратите внимание на{this.css}. JSX не позволяет вставить CSS напрямую. Поэтому сохраним его в переменную и будем использовать динамически. Присвоение значения происходит в конструкторе:

this.css = `  
html { filter: invert(100%); background: #fefefe; }
* { background-color: inherit }
img:not([src*=".svg"]), video { filter: invert(100%) }`;

Преодолеваем проблемы браузеров

К сожалению, применение стилей простым изменением media="none" на media="screen" работает не во всех браузерах. Как выяснилось, чтобы страница была перерисована, необходимо изменить содержимое тега <style>. Простейшее из найденных для этого решений — метод trim(). Забавно, что, кажется, только Chrome нуждается во всех этих манипуляциях.

{this.isActive() ? this.css.trim() : this.css}

Сохраняем выбранную тему

Cохранить выбор пользователя помогут localStorage и методы жизненного цикла React. Для начала, создадим псевдоним внутри конструктора, он поможет избежать ошибок, возможных при прямом обращении к localStorage.

this.store = typeof localStorage === 'undefined' ? null : localStorage;

С помощью метода componentDidMount мы можем получить и применить сохраненную тему. Значение по умолчанию — false: оно для случаев, если метка в хранилище еще отсутствует.

componentDidMount() {  
if (this.store) {
this.setState({
active: this.store.getItem('ThemeSwitch') || 'false'
});
}
}

Так как в React состояния работают асинхронно, мы не можем просто сохранить его после изменения. Это было бы недостаточно надежно. Лучший вариант — использовать метод componentDidUpdate:

componentDidUpdate() {  
if (this.store) {
this.store.setItem('ThemeSwitch', this.state.active);
}
}

Прячем от браузеров без поддержки

Некоторые браузеры всё ещё не умеют работать с filter: invert(100%). Для них стоит убрать переключатель вовсе. Уж лучше когда его нет, чем когда он есть, но не работает. Определим поддержку с помощью функции invertSupported и сохраним результат в состояние supported.

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

invertSupported (property, value) {  
var prop = property + ':',
el = document.createElement('test'),
mStyle = el.style;
el.style.cssText = prop + value;
return mStyle[property];
}
componentDidMount() {
if (this.store) {
this.setState({
supported: this.invertSupported('filter', 'invert(100%)'),
active: this.store.getItem('ThemeSwitch') || 'false'
});
}
}

В JSX состояние supported поможет принять решение о скрытии компонента. Само скрытие реализуем свойством hidden.

<div hidden={!this.state.supported}>  
<!-- Здесь содержимое компонента -->
</div>

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

[hidden] {
display: none;
}

Другой вариант — не рендерить содержимое компонента вовсе. Для этого потребуется несколько экстравагантный тернарный оператор внутри JSX:

return (  
<div>
{
(this.state.supported)
? <div>
<button aria-pressed={this.isActive() ? 'true' : 'false'} onClick={this.toggle}>
dark theme:
<span aria-hidden="true">{this.isActive() ? 'on' : 'off'}</span>
</button>
<style media={this.isActive() ? 'screen' : 'none'}>
{this.isActive() ? this.css.trim() : this.css}
</style>
</div>
: ''
}
</div>
);

Режим высокой контрастности Windows

Windows предлагает ряд высококонтрастных режимов (WHCM) на уровне операционной системы. Среди них — белый текст на черном фоне, как наша тема. Так что, думаю, важно обеспечить максимальную поддержку WHCM нашим компонентом. И вот несколько советов по этому поводу:

  • Не делайте контентные изображения фоновыми. Наша «ночная» тема их инвертирует, а в случае с WHCM они и вовсе будут невидны. Для важных смыслообразующих изображений используйте элементы <img> с описанием в атрибуте alt.
  • Для инлайновых SVG значения fill и stroke задавайте через currentColor. Тогда при активации высококонтрастного режима цвет иконки будет меняться соответственно цвету текста вокруг.
  • Любые другие, специальные изменения для WHCM можно сделать с помощью медиавыражения:
@media (-ms-high-contrast: active) { 
/* Здесь код, специфичный для WHCM */
}

Свойство preserveRasters

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

Почему бы не предусмотреть выбор и в нашем случае — будут растровые изображения инвертированы, как все другие элементы, или нет. Я добавил свойство preserveRasters со значением true или false. В коде оно выглядит так:

<ThemeSwitch preserveRasters="false"></ThemeSwitch>

В месте, где определяется CSS, добавим условие, что изображения должны быть инвертированы обратно, только при preserveRasters === 'true'.

this.css = `  
html { filter: invert(100%); background: #fefefe; }
* { background-color: inherit }
${this.props.preserveRasters === 'true' ? `img:not([src*=".svg"]), video { filter: invert(100%) }` : ``}`;

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

Значение по умолчанию

Чтобы добавить компоненту надежности, но в тоже время оставить разработчику возможность настройки, предусмотрим значение по умолчанию — defaultProp. Для этого, после определения класса компонента добавим в код строку:

ThemeSwitch.defaultProps = { preserveRasters: 'true' }

Установка компонента

Наш компонент доступен в NPM:

npm i --save react-theme-switch

Кроме того, есть версия на простом JavaScript, которая работает на чекбоксе. Поиграйте с ней на CodePen:

Расположение

Всё, что осталось — это решить, в каком месте документа расположить компонент. Как правило, элементы-утилиты, вроде переключателей тем, должны находиться внутри смыслового блока с ARIA-атрибутом role, но не внутри <main>. Дело в том, что пользователи скринридеров ожидают, что содержимое <main> меняется от страницы к странице. Что касается <header> (role="banner") и <footer> (role="contentinfo"), то оба они подходят.

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

Чеклист

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

--

--