От автора: первое, что мне сказали, когда я начал изучать веб-стандарты около двенадцати лет назад, было: «Не используй таблицы для макета». Это был довольно разумный совет, но не до конца разъяснённый. В результате чего появлялись неудачные интерпретации. Использование разметки таблиц неизбежно приводит к визуальному оформлению, что заставило многих вообще отказаться от HTML-таблиц. Таблица данных: плохо.
Совет «не использовать таблицы для макета» заключается не в том, чтобы использовать HTML-элементы способами, для которых они не были предназначены. Двенадцать лет назад идея, что я буду кодировать «неправильный» HTML, была достаточной, чтобы не дать мне сделать такие классические промахи. Однако тщеславие — не настоящая причина.
Настоящая причина — это причина, по которой эта плохая практика влияет на пользователя и как она это делает. Разметка таблиц, начиная с table, в том числе th, td и др. позволяет браузерам передавать определенную информацию и производить определенные виды поведения. Тот, кто использует вспомогательное программное обеспечение, вроде скринридеров, станет объектом этой информации и поведения.
Когда табличная разметка содержит не-табличный контент, слепым пользователям бесполезно чего-либо ожидать. Это не макет страницы; это таблица данных, которая не имеет смысла. Если они видны или частично показаны и работают со скринридером, то всё это представляется еще более запутанным.
Наш способ оценки веб-технологий — это эпохальный диковинный. Мы считаем, что одна эпоха — эпоха CSS Flexbox, например, должна закончиться, поскольку она переходит в новую эпоху CSS Grid. Но, как и div макеты страниц и таблицы данных, это на самом деле взаимодополняющие вещи, которые могут сосуществовать. Вам просто нужно знать, где использовать одно, а где другое.
В этой статье я буду рассказывать, как создавать инклюзивные таблицы данных: те, которые доступны для чтения с экрана, отзывчивы и максимально эргономичны для всех. Но первым делом, я хочу показать вам трюк для фиксации старой таблицы макетов.
Роль презентации
WAI-ARIA может быть полезным инструментом, поскольку он позволяет добавлять и расширять семантическую информацию в HTML. Например, добавление aria-pressed — стандартной кнопки — делает ее переключающей кнопкой для браузеров и, следовательно, вспомогательного программного обеспечения. Но знаете ли вы, что вы также можете использовать WAI-ARIA для удаления семантики? То есть следующие два элемента являются семантически неопределенными для скринридера. Также как «button».
1 2 |
<button role="presentation">Press me</button> <span>Press me</span> |
В большинстве случаев вам нужно только добавить семантику, где она нужна, вместо того, чтобы выбирать элементы для их внешнего вида и удалять семантику там, где она не нужна. Но иногда обратная инженерная доступность информации это наиболее эффективный способ сделать плохое решение, вроде таблицы макета, хорошим.
Применение role=»presentation» к table элементу удаляет всю семантику этой таблицы и, следовательно, выявляет поведение скринридеров. Это как, если бы он был построен с использованием семантически непритязательных div.
Обратите внимание, что role=»presentation»и role=»none» являются синонимами. Первый — более продолжительный и более удобный.
В 2018 году в любом случае есть гораздо лучшие решения для компоновки, чем в table, поэтому нет смысла использовать их для любого нового макета, который вы пытаетесь выполнить.
Настоящие таблицы данных
Типичная таблица макетов состоит из table контейнера, нескольких tr и нескольких td внутри них.
1 2 3 4 5 6 7 8 9 10 |
<table> <tr> <td><img src="some/image" alt=""></td> <td>Lorem ipsum dolor sit amet.</td> </tr> <tr> <td><img src="some/other/image" alt=""></td> <td>Integer vitae blandit nisi.</td> </tr> </table> |
Проблема односторонней семантики, это все элементы, которые вам действительно нужны для визуального оформления. У вас есть строки и столбцы, как сетка.
К сожалению, даже если мы намерены изготовить таблицу данных, мы все же склонны думать только визуально: «Если это выглядит как таблица, всё в порядке». Но это не создает доступную таблицу.
1 2 3 4 5 6 7 8 9 10 |
<table> <tr> <td>Column header 1</td> <td>Column header 2</td> </tr> <tr> <td>Row one, first cell</td> <td>Row one, second cell</td> </tr> </table> |
Почему? Потому что наши заголовки столбцов семантически стандартные элементы таблицы. В них нет ничего кроме текста (который, вероятно, будет менее ясным в реальном примере, чем «Column header 1»). Вместо этого нам нужно сделать их th элементами.
1 2 3 4 5 6 7 8 9 10 |
<table> <tr> <th>Column header 1</th> <th>Column header 2</th> </tr> <tr> <td>Row one, first cell</td> <td>Row one, second cell</td> </tr> </table> |
Использовать столбцы заголовков таким образом «семантически корректно». Существует явное влияние на поведение скринридера. Теперь, если я использую свой скринридер для перехода к ячейке строки, он зачитает заголовок, под которым сидит, и сообщит мне, в каком столбце я нахожусь.
Заголовки строки
В таблицах данных возможно наличие столбцов и заголовков строки. Я не могу думать о каких-либо данных, для которых заголовки строки строго необходимы для понимания, но иногда кажется, что ключевое значение для строки таблицы должно быть выделены и находиться слева.
Проблема в том, что, если вы не указали это, неясно, загорается ли ячейка заголовка под ним или справа. Вот тут нужен scope атрибут. Для заголовков столбцов вы используете scope=»col», а для заголовков строки scope=»row». Вот пример цен на топливо, над которыми я недавно работал для Bulb.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
<table> <tbody> <tr> <th scope="col">Region</th> <th scope="col">Electricity</th> <th scope="col">Gas</th> </tr> <tr> <th scope="row">East England</th> <td>10.40</td> <td>2.31</td> </tr> <tr> <th scope="row">East Midlands</th> <td>10.55</td> <td>2.77</td> </tr> <tr> <th scope="row">London</th> <td>10.10</td> <td>2.48</td> </tr> </tbody> </table> |
Обратите внимание, что не устанавленный заголовок строки не приведёт к конфликту данных; он просто добавит дополнительную ясность и контекст. В таблицах, в которых используются заголовки столбцов и строк, некоторые скринридеры будут объявлять как столбцы, так и метки строк для каждой из ячеек данных.
Заметка
Использование таблиц со скринридерами
Сложные интерфейсы и виджеты, как правило, имеют особое поведение и связанные с ними сочетания клавиш в экранных программах, а таблицы ничем не отличаются.
JAWS, NVDA и VoiceOver предоставляют [T] ключ для перемещения между таблицами на странице. Чтобы перемещаться между ячейками таблицы, вы используете клавиши со стрелками. Когда вы переходите к таблице, вам обычно сообщают, сколько столбцов и строк она содержит. Кроме того считывается caption, если присутствует.
Когда вы переключаетесь между ячейками по столбцам, объявляется новый заголовок столбца, а также числовое размещение столбца (например, «столбец 3 из 4») и содержимое самой ячейки. Когда вы переключаетесь между ячейками по строкам, объявляется новый заголовок строки вместе с числовым размещением строки (например, «строка 5 из 8») и содержимым самой ячейки.
Титры
Раньше существовали два способа предоставления описательной информации непосредственно в таблице: caption и summary. Элемент summary устарел в HTML5, поэтому его следует избегать. Элемент caption обеспечивает визуаллизации и скринридеру доступный ярлык. Элемент summary работает больше как alt атрибут и не заметен, поскольку сама таблица предоставляет текстовую информацию, такое итог не является необходимостью.
Не всем таблицам обязательно нужны титры, но рекомендуется указывать заголовок или предшествовать таблицу заголовком. То есть, если таблица не находится внутри figure с figcaption. Как подсказывают названия, figcaption это своего рода заголовок, и этого достаточно.
Преимущество надписи над заголовком заключается в том, что он считывается, когда пользователь экрана считывает информацию непосредственно из таблицы с помощью [T] сочетания клавиш. К счастью, HTML5 позволяет размещать заголовки внутри заголовков, что является лучшим в обоих случаях, и настоятельно рекомендуется, когда вы знаете, на каком уровне заголовок должен быть впереди времени.
Управляемый компонент таблицы данных
Это в значительной степени охватывает основные таблицы и то, как сделать их доступными. Беда в том, что это своего рода проблема, чтобы кодировать вручную, и большинство инструментов WYSIWYG для создания таблиц не выводят приличную разметку с необходимыми заголовками в правильных местах.
Вместо этого давайте создадим компонент, который принимает данные и автоматически выводит доступную таблицу. В React мы можем поставить заголовки и строки в качестве реквизита. В случае с заголовками нам нужен массив. Для строк: массив массивов (или «двумерный» массив).
1 2 3 4 5 6 7 8 |
const headers = ['Band', 'Singer', 'Inception', 'Label']; const rows = [ ['Napalm Death', 'Barney Greenway', '1981', 'Century Media'], ['Carcass', 'Jeff Walker', '1985', 'Earache'], ['Extreme Noise Terror', 'Dean Jones', '1985', 'Candlelight'], ['Discordance Axis', 'Jon Chang', '1992', 'Hydrahead'] ]; |
Теперь Table компонент просто нуждается в const.
1 |
<Table rows={rows} headers={headers} /> |
Одна из лучших и худших вещей в HTML заключается в том, что он всё прощает. Вы можете написать плохо сформированный, недоступный HTML, и браузер все равно сделает его без ошибок. Это делает веб-платформу возможной для новичков, и тех, кто создает эксперименты по прерыванию правил. Но это не позволяет нам учитывать, когда мы пытаемся создать хорошо сформированный код, совместимый со всеми парсерами, включая вспомогательные технологии.
Отложив хорошо сформированную часть к массивам, которые ожидают очень специфическую структуру, мы можем словить ошибки. Если массивы хорошо сформированы, мы можем автоматически генерировать из них доступную разметку.
Вот как выглядит основной компонент, который это обрабатывает:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class Table extends React.Component { render() { return ( <table> <tr> {this.props.headers.map((header, i) => <th scope="col" key={i}>{header}</th> )} </tr> {this.props.rows.map((row, i) => <tr key={i}> {row.map((cell, i) => <td key={i}>{cell}</td> )} </tr> )} </table> ); } } |
Если вы не предоставите массивы для реквизитов headers и rows, все будет идти не так эффектно, так что если вы изучаете ошибки типа «не является функцией», смотрите дальше.
Возможно, было бы лучше увидеть эти ошибки на ранней стадии и вывести более полезное сообщение. Здесь могут быть полезны «prop types».
1 2 3 4 |
Table.propTypes = { headers: PropTypes.array.required, rows: PropTypes.array.required }; |
Конечно, если вы используете TypScript, то, вероятно, будете передавать это с помощью интерфейса . Я лично нахожу в Typcript крайне жесткий и запутанный синтаксис React. Мне говорят, что это здорово, когда пишешь сложное корпоративное программное обеспечение, но если вы в основном имеете дело с небольшими проектами и кодовыми базами, то жизнь, вероятно, слишком коротка.
Поддержка заголовков строки
Поддержка опции заголовков строк — это пара пустяков. Нам просто нужно знать, включил ли автор основу rowHeaders. Тогда мы можем преобразовать первую ячейку каждой строки в th с scope=»row».
1 2 3 4 5 6 7 8 9 |
<tr key={i}> {row.map((cell, i) => (this.props.rowHeaders && i < 1) ? ( <th scope="row" key={i}>{cell}</th> ) : ( <td key={i}>{cell}</td> ) )} </tr> |
В моей таблице с грайндкор группами это имеет большой смысл, поскольку группы, названные в левой части, являются основой для всей другой информации. Вот демоверсия основного компонента таблицы codePen, включающая всего 25 строк:
Действующее отображение
Отображение таблиц — одна из тех областей, где доступное решение больше касается того, что вы не делаете, чем того, что вы делаете. Как недавно заметил Адриан Розелли, использование свойств отображения CSS для изменения макета таблицы имеет тенденцию удалять семантику базовой таблицы. Это, вероятно, такого быть не должно, потому что это противоречит разделению принципов. Так или иначе.
Это не единственная причина, по которой трудно изменить способ отображения таблиц. Визуально выражаясь, на самом деле это не та же самая таблица — или большая часть таблицы вообще — если столбцы и строки рушатся друг за другом. Вместо этого мы хотим обеспечить доступ к одной и той же визуальной и семантической структуре независимо от свободного места.
Это так же просто, как позволить родительскому элементу таблицы прокручиваться по горизонтали.
1 2 3 |
.table-container { overflow-x: auto; } |
Поддержка клавиатуры
Да, все не так просто. Как мы помним из A Content Slider, нам нужно сделать прокручиваемый элемент фокусируемым, чтобы его можно было использовать с клавиатуры. Это всего лишь случай добавления tabindex=»0″. Но так как пользователи, занимающиеся чтением экрана, должны также сосредоточить внимание на них, нам нужно предоставить им какой-то контекст.
В этом случае я буду использовать таблицу caption для маркировки области прокрутки с использованием aria-labelledby.
1 2 3 4 5 6 |
<div class="table-container" tabindex="0" role="group" aria-labelledby="caption"> <table> <caption id="caption">Grindcore bands</caption> <!-- table content --> </table> </div> |
Заметки
Как я писал в ARIA-label Is A Xenophobe , услуги перевода, такие как Google, не переводят этот aria-label атрибут, поэтому лучше пометить его с помощью текстового узла элемента. Мы можем это сделать aria-labelledby. Уникальный шифр, совместно используемый aria-labelledby и id может быть сгенерирован при использовании React Math.random().
Вы нигде не можете использовать aria-labelledby. Элемент должен иметь соответствующее значение role. Здесь я использую довольно общую групповую роль для этой цели. Из спецификации группыы «on»: «Набор объектов пользовательского интерфейса, которые не предназначены для включения в сводку страниц или оглавление с помощью вспомогательных технологий».
Фокусируется только там, где прокручивается
Конечно, мы не хотим, чтобы контейнер таблицы был сфокусирован, если его содержимое не переполнено. В противном случае мы добавляем стоп-кадр к порядку фокуса, который ничего не делает. На мой взгляд, это будет неудачей 2.4.3 Focus Order. Предоставление элементов пользователям клавиатуры для фокусировки, которые на самом деле ничего не делают, мешает и сбивает с толку.
Что мы можем сделать, так это определить, переполняется ли содержимое при загрузке страницы (или установке компонента), добавляя tabindex=»0″, только если scrollWidth превышает clientWidthего для контейнера. Для этой цели мы можем использовать ref (this.container ).
1 2 3 4 5 6 7 |
componentDidMount() { const {scrollWidth, clientWidth} = this.container; let scrollable = scrollWidth > clientWidth; this.setState({ tabindex: scrollable ? '0' : null }); } |
(Спасибо Альмеро Стейн за заметки на строке депрекации ref . Как он отметил, в React 16.3 вы определить ref в конструкторе как this.container = React.createRef();. Тогда просто нужно добавить ref={this.container}в элемент контейнера.)
Вот усеченная версия скрипта, показывающая, как я использую состояние tabindex для переключения значения через componentDidMount функцию жизненного цикла.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
class Table extends React.Component { constructor(props) { super(props) this.state = { tabindex: null } } componentDidMount() { let container = ReactDOM.findDOMNode(this.refs.container); let scrollable = container.scrollWidth > container.clientWidth; this.setState({ tabindex: scrollable ? '0' : null }); } render() { const captionID = 'caption-' + Math.random().toString(36).substr(2, 9); return ( <div className="table-container" ref="container" tabIndex={this.state.tabindex} aria-labelledby={captionID} > <!-- table here --> </div> ); } } |
Воспринимаемая возможность
Недостаточно, чтобы пользователи могли прокручивать таблицу. Им также нужно знать о том, что они могут прокручивать таблицу. К счастью, учитывая наш стиль границы ячейки таблицы, должно быть очевидно, что указывает на то, что некоторый контент вне поля зрения, когда таблица отключена.
Мы можем сделать всё это лучше, просто чтобы быть в безопасности, и зацепиться за состояние и отобразить сообщение в заголовке:
1 2 3 4 5 |
{this.state.tabindex === '0' && <div> <small>(scroll to see more)</small> </div> } |
Этот текст также станет частью метки прокручиваемого контейнера (через aria-labelledby ассоциацию, обсуждаемую ранее). В скринридере, когда скроллируемый контейнер сфокусирован, вы услышите нечто похожее на «Грайндкор группы, открытые круглые скобки, прокрутите, чтобы увидеть больше, закрытые круглые скобки, группа». Другими словами, это добавочное сообщение добавляет разъяснения не визуально.
Очень узкий вьюпорт
То, о чём говорилось ранее, работает для широких таблиц (со многими столбцами) или узких вьюпортов. Однако очень узкие вьюпорты могут потребовать чего-то более радикального. Если вы едва можете увидеть один столбец за раз, просмотр ужасно неудобный- даже если вы можете физически прокручивать другие столбцы тачем.
Вместо этого для очень узких (одного столбца) вьюпортов мы можем представить данные с использованием другой структуры с заголовками и списками определений.
<caption> → <h2>
<th scope=»row»> → <h3>
<th scope=»col»> → <dt>
<td> → <dd>
Эта структура гораздо больше подходит для мобильных устройств, где пользователи привыкли к прокрутке по вертикали. Она также доступна иначе.
Вот как выглядит JSX:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<div className="lists-container"> <h2>{this.props.caption}</h2> {this.props.rows.map((row, i) => <div key={i}> <h3>{row[0]}</h3> <dl> {this.props.headers.map((header, i) => i > 0 && <React.Fragment key={i}> <dt>{header}</dt> <dd>{row[i]}</dd> </React.Fragment> )} </dl> </div> )} </div> |
Обратите внимание на использование Fragment. Это позволяет нам выводить развернутые элементы dt и dd элементы для нашей dl структуры. Последние изменения в спецификации сделали возможным обернуть dt/dd пару в div (спасибо Gunnar за разъяснения по этому поводу). Но мы не можем быть уверены, что на данный момент это не вызовет проблемы с анализом, и что нам вообще нужны обертки.
Все, что осталось сделать, это показать / скрыть эквивалентные интерфейсы на соответствующей ширине вьюпортов. Например:
1 2 3 4 5 6 7 8 9 |
@media (min-width: 400px) { .table-container { display: block; } .lists-container { display: none; } } |
Для чрезвычайно больших наборов данных наличие обоих интерфейсов в DOM раздувает слишком большое дерево DOM. Однако в большинстве случаев это более эффективное решение по сравнению с динамическим восстановлением DOM через matchMedia или (что еще хуже) при прослушивании resize события.
Если вы загружаете динамические данные, вам не нужно беспокоиться о том, что два интерфейса остаются в синхронизации: они основаны непосредственно на одном источнике.
Сортируемые таблицы
Давайте дадим пользователям некоторый контроль над тем, как сортируется контент. В конце концов, у нас уже есть данные в отсортированном формате — двумерный массив.
Конечно, с таким небольшим набором данных, просто для демонстрационных целей, сортировка на самом деле не нужна. Но давайте все равно её реализуем, в случаях, когда это облегчает ситуацию. Самое замечательное в реквизитах React — это то, что мы можем легко включить или отключить функцию.
Внутри каждого заголовка столбца мы можем предоставить кнопку сортировки:
Они могут переключаться между сортировкой данных по столбцу в порядке возрастания или убывания. Коммуникация метода сортировки — это задание aria-sort свойства. Обратите внимание, что он работает наиболее надежно в сочетании с явным role=»columnheader».
Вот начальная колонка, сообщающая восходящую сортировку (наименьшее значение) для чтения с экрана. Другими возможными значениями являются descending и none.
1 2 3 4 |
<th scope="col" role="columnheader" aria-sort="ascending"> Inception <button>sort</button> </th> |
Не все скринридеры поддерживают aria-sort, но ярлык кнопки сортировки «sort by [column label]» делает вещи достаточно ясными для тех, у кого нет состояния сортировки. Вы могли бы сделать лучше, адаптировав ярлык для сортировки по [метка столбца] [‘ возрастанию’ | ‘убыванию]] порядка «.
1 |
aria-label={`sort by ${header} in ${this.state.sortDir !== 'ascending' ? 'ascending' : 'descending'} order`} |
Иконография
Визуально порядок сортировки должен быть достаточно ясным, если посмотреть на столбец, но мы можем пойти иначе, предоставив значки, которые связывают одно из трех состояний:
↕ = сортируемое, но не сортируется
↑ = сортируется в этом столбце в порядке возрастания
↓ = сортируется в этом столбце по убыванию
Как всегда, выгодно использовать SVG.
Масштаб SVG без деградации делает увеличение более приятным
Использование SVG currentColor с соблюдением настроек Windows High Contrast
SVG можно эффективно создавать из элементов формы и линии
SVG — это разметка, и её разные части могут быть нацелены индивидуально
Последнее преимущество — это не то, что я изучил по инклюзивным компонентам.design раньше, но он идеально подходит сюда, потому что каждая стрелка состоит из двух или более строк. Рассмотрим следующий Arrow компонент.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
const Arrow = props => { let ascending = props.sortDir === 'ascending'; return ( <svg viewBox="0 0 100 200" width="100" height="200"> {!(!ascending && props.isCurrent) && <polyline points="20 50, 50 20, 80 50"></polyline> } <line x1="50" y1="20" x2="50" y2="180"></line> {!(ascending && props.isCurrent) && <polyline points="20 150, 50 180, 80 150"></polyline> } </svg> ); } |
Логика передается от родительского компонента через реквизиты ( sortDir и current), чтобы условно показать разные polyline головки стрелок. Например, конечная polyline отображается только в том случае, если верно следующее.
Порядок сортировки не возрастает
Это не текущий столбец сортировки
Предупреждение: Технически здесь я использую стрелку, чтобы выразить текущее состояние кнопки, а не состояние нажатия на нее. Во многих случаях (как и описано в Toggle Buttons ) это ошибка. Важным здесь является изменение направления стрелки как переключателя, сообщая параметр в полярности.
Заметка
Замечание о роли сетки
WAI-ARIA обеспечивает роль grid, которая тесно связана с таблицами. Эта роль предназначена для сопряжения с конкретным поведением клавиатуры, позволяя пользователям клавиатуры перемещаться по ячейкам таблицы, поскольку они могли бы работать с программным обеспечением для чтения с экрана (используя клавиши со стрелками).
Вам не нужно использовать grid, чтобы сделать большинство таблиц доступными для чтения с экрана. Поведение связанное grid должно быть реализовано только тогда, когда пользователю не нужно запускать скринридер, чтобы получить доступ к каждой ячейке и взаимодействовать с ними. Одним из примеров может быть сборщик дат, в котором каждая дата может быть нажата в виде сетки в календарном месяце.
Представление
Функция сортировки должна использовать sort метод и выглядеть примерно так и:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
sortBy(i) { let sortDir; let ascending = this.state.sortDir === 'ascending'; if (i === this.state.sortedBy) { sortDir = !ascending ? 'ascending' : 'descending'; } else { sortDir = 'ascending'; } this.setState(prevState => ({ rows: prevState.rows.slice(0).sort((a, b) => sortDir === 'ascending' ? a[i] > b[i] : a[i] < b[i]), sortedBy: i, sortDir: sortDir })); } |
Обратите внимание на использование slice(0). Если бы этого не было, sort метод мог бы напрямую увеличить исходные данные (что является необычной характерной особенностью sort). Это означало бы, что и таблица, и структура списка мобильной ширины будут перестроены в DOM. Поскольку для структуры списка нет элементов управления сортировкой, это лишний удар по производительности.
Демо
В Github доступна полная демоверсия, включая заголовки строки, выборочную прокрутку, альтернативное представление для мобильных устройств и функцию сортировки.
Вывод
Да, все равно таблицы можно использовать . Только не используйте их, если они не нужны, а когда вы действительно в них нуждаетесь, структурируйте их логичным и ожидаемым образом.
Источник: //inclusive-components.design/
Редакция: Команда webformyself.