Недавно у меня была возможность обсудить трудности, уроки и успехи в разработке Spectrum Web Components вместе с коллегами-разработчиками пользовательских элементов из команд IBM, ING, SAP и Vaadin (запись можно посмотреть на youtube). Один из участников панельной дискуссии, Ари Гилмор, справедливо отметил, что разработчикам, таким как мы, не хватает материалов, которые можно было бы использовать для внедрения надёжных практик доступности в области веб-компонентов.
С учётом этого, я решил, что было бы полезно превратить некоторые из абстрактных концепций, обсуждавшихся на встрече, в конкретные примеры рабочего и тестируемого кода. Надеюсь, это поможет следующим разработчикам, стремящимся создать для своей команды высококачественную, доступную дизайн-систему с помощью веб-компонентов.
В рамках этой темы я реализую паттерн элемента input с доступной меткой (label) и текстом подсказки. Следуя предложению Томаса Аллмера и команды из ING, первый пример будет без использования теневого DOM (Shadow DOM) и с соответствующим тестированием. На основе общего понимания того, как работает HTML и тестирование, мы исследуем несколько примеров реализации связи между элементом <input>, элементом <label> и текстом подсказки с использованием пользовательских элементов и теневого DOM. Мы также обсудим способы комбинирования этих подходов и о том, как некоторые из них соотносятся с черновыми спецификациями и разработками, которые делают этот процесс менее трудоёмким.
Некоторые ключевые темы, которые мы обсуждали на панельной дискуссии и которые я затрону в этой статье:
использование движка тестирования доступности axe-core,
понимание и анализ дерева доступности,
тестирование с использованием нативных взаимодействий с клавиатурой,
как ссылки по ID не проходят через границы теневого DOM,
способы имитации ссылок на нативные элементы.
Я также свяжу эти техники с различными библиотеками веб-компонентов, которые мои коллеги по панельной дискуссии внедрили в свои проекты, чтобы вы могли понять, как адаптировать их для своих проектов.
Другие темы, которые я не буду подробно освещать в этой статье, но которые крайне важны при создании качественных, готовых к продакшену реализаций этих паттернов:
стилизация содержимого,
ассоциация форм,
управление состоянием.
валидация.
Каждая из этих тем заслуживает отдельной статьи. Надеюсь, что те идеи, которые вы найдёте здесь, помогут вам сделать содержимое, основанное на теневом DOM, более доступным и освободят время для того, чтобы вы смогли поделиться своим подходом к этим аспектам в будущем.
Дисклеймер
Перед тем как начать: я не считаю себя специалистом по доступности. Я понимаю, что доступность — это важная часть создания продуктов для людей, и стараюсь, чтобы инструменты, которые я использую, становились всё более доступными со временем. В своей команде я работаю с умными и неравнодушными людьми, чтобы находить новые и более эффективные способы решения задач. В Adobe, занимаясь разработкой Spectrum Web Components, я сотрудничаю с выделенной командой инженеров по доступности, некоторые из которых даже пишут спецификации, по которым работает всё веб-сообщество. Без их терпения и поддержки я бы не достиг того уровня, который позволил мне создавать доступные интерфейсы для веба.
Начнём с HTML
Следуя примеру команды ING, чья основательная работа привела к созданию библиотеки Lion, мы начнём с базового HTML-паттерна, который обеспечивает метку и описание для элемента <input>
:
<div>
<label for="input">Label</label>
<input id="input" aria-describedby="description" />
<div id="description">Description</div>
</div>
Вы можете посмотреть демо этого кода или клонировать его с GitHub, чтобы изучить, как он работает. Однако суть функционала (на данный момент всё предоставляется нативно HTML) заключается в использовании ссылок по ID.
Наш элемент <label>
принимает атрибут for
, который ссылается на элемент по ID. Элемент, на который ссылается этот атрибут (в данном случае это наш элемент <input>
), получает содержимое элемента <label>
в качестве своего «имя» в дереве доступности, которое передаётся скринридером. Локально это также позволяет передавать события click
и focus
, вызванные при нажатии на <label>
, связанному элементу. Это менее важно для элемента <input type="text">
, однако для таких типов, как checkbox
или radio
, эти события будут переключать их состояния, что упрощает взаимодействие и стилизацию вашего контента формы.
Элемент <input>
, о котором идёт речь, использует атрибут aria-describedby
, который также ссылается на элемент по ID. Здесь атрибут указывает на наш элемент <div>
, содержащий описание. Эта связь не предоставляет интерактивной функциональности по умолчанию, но текст содержимого указанного элемента будет передан как «описание» для элемента <input>
в дереве доступности.
Что и как тестировать
Всё это — отличное начало для реализации данного паттерна с поддержкой доступности, но не стоит полагаться только на мои слова. Давайте разберёмся, как можно проверить эти утверждения, а заодно подготовимся к рефакторингу этого паттерна с использованием API веб-компонентов, таких как пользовательские элементы и теневой DOM.
axe-core
Спасибо, @open-wc/testing!
Первым шагом в любом тестировании доступности (а может, и в любом UI-тестировании) должны быть тесты, которые можно получить «бесплатно». Для нашего случая это может быть обеспечено с помощью движка тестирования доступности axe-core, упакованного в Chai a11y Axe и предоставленного библиотекой @open-wc/testing. Хотя ранее я упоминал о том, что автоматизированное тестирование, подобное этому, может покрывать лишь небольшую часть проблем, меня радует недавнее исследование компании Deque (создателей axe-core), которое показывает, что 57,38% проблем с доступностью можно обнаружить с помощью автоматизации. Это будет важным первым шагом в подтверждении доступности создаваемых вами паттернов.
Более того, этот значительный первый шаг на самом деле очень прост. Ознакомьтесь с кодом ниже, чтобы увидеть, как проверить доступность DOM-фрагмента с помощью axe-core через Chai a11y Axe:
// Имеет побочный эффект в виде привязки Chai A11y aXe к `expect`
import { fixture, expect } from '@open-wc/testing';
// ...
it('проходит аудит aXe-core', async () => {
const el = await fixture<HTMLDivElement>(`
<div>
<label for="input">Метка</label>
<input id="input" aria-describedby="description" />
<div id="description">Описание</div>
</div>
`);
// Асинхронно проверяет доступность предоставленного контента
await expect(el).to.be.accessible();
});
Совершенно верно, ключевая часть теста — await expect(el).to.be.accessible();
— сразу же начнёт предоставлять отчёты о доступности DOM в вашей фикстуре. Ознакомьтесь с описаниями правил, чтобы узнать обо всех концепциях, которые будут покрыты всего одним тестом.
Этот тест настолько важен, что многие инструменты заранее включают его, чтобы убедиться, что он используется с самого начала. При запуске npm init @open-wc
, этот тест будет добавлен по умолчанию, когда генератор добавляет testing
. В Spectrum Web Components, при создании нового пакета с помощью шаблонизации Plop, этот тест также добавляется автоматически. Независимо от того, как вы инициализируете свои проекты, я настоятельно рекомендую вам по умолчанию включать такой тест.
С этим шагом позади и с уже выявленными 57,38% проблем с доступностью, мы можем рассмотреть более сложные контексты, которые стоит охватить.
Дерево доступности
Спасибо, @web/test-runner!
DOM-дерево, в сочетании с различными ссылками по ID и атрибутами aria-*
, используется браузером для построения дерева доступности, которое он представляет скринридерам, чтобы помочь пользователям взаимодействовать с вашим контентом. Опираясь на рекомендации WAI-ARIA Authoring Practices а также на гарантии, которые предоставляет axe-core как базу, мы можем быть уверены в том, каким образом браузер построит дерево на основе вашего контента. Однако полезно знать это наверняка.
Один из способов — использовать полное представление дерева доступности в Chrome DevTools. Включив его, вы сможете вручную проверить дерево, в которое ваш код преобразуется браузером. Многие браузеры дают возможность частично увидеть дерево доступности через инструменты разработчика, что очень полезно; но зачастую вам всё равно придётся полагаться на ручное тестирование, чтобы убедиться, что скринридеру передаётся правильнон содержимое.
Хотя ручное тестирование обязательно должно быть частью вашей стратегии подготовки к продакшену, знание того, что именно находится в этом дереве — не только созданные связи, но и сам контент, связанный с этими отношениями, — может стать важным дополнением к вашему процессу автоматизированного тестирования. Для этого инструменты для запуска браузера, такие как Playwright, предоставляют API, позволяющие напрямую получить доступ к дереву доступности (например, в виде его снимка).
@web/test-runner
предоставляет доступ к этим API во время модульного тестирования через интерфейс команд. Это позволяет сделать снимок дерева доступности в любой момент во время взаимодействия с вашим кодом во время тестирования и подтвердить, что узлы и связи, которые вы ожидаете увидеть, действительно присутствуют.
import { a11ySnapshot, findAccessibilityNode } from '@web/test-runner-commands';
// ...
const label = 'Label';
const description = 'Description';
it(`is labelled "${label}" and described as "${description}"`, async () => {
const el = await fixture<HTMLDivElement>(`
<div>
<label for="input">${label}</label>
<input id="input" aria-describedby="description" />
<div id="description">${description}</div>
</div>
`);
const snapshot = (await a11ySnapshot({})) as unknown as DescribedNode & {
children: DescribedNode[];
};
const describedNode = findAccessibilityNode(
snapshot,
(node) =>
node.name === label &&
node.description === description
);
expect(describedNode, `node not in: ${JSON.stringify(snapshot, null, ' ')}`).to.not.be.null;
});
Приведённый выше код создаёт HTML-реализацию поля ввода с меткой и описанием, добавляет её в документ, а затем запрашивает снимок дерева доступности. Далее, с помощью вспомогательной функции findAccessibilityNode
проверяется наличие узла, соответствующего заданным требованиям. Также обратите внимание, что я использую собственное сообщение об ошибке в ожиданиях Chai, чтобы в случае сбоя теста можно было увидеть строковое представление дерева доступности. Поскольку дерево может содержать множество крайних случаев и неожиданных результатов, я считаю это важной частью понимания того, что именно я тестирую.
Один из случаев, который меня удивил, связан с тем, что WebKit не активно связывает содержимое описания с нашим элементом <input>
. Хотя ручное тестирование подтверждает, что текст описания корректно связан, дерево доступности не отображает связь между этими двумя элементами. Учитывая поддержку этих отношений через ручное тестирование в браузере WebKit, я готов расширить тест таким образом, чтобы учитывать это отклонение:
export const findDescribedNode = async (
name: string,
description: string
): Promise<void> => {
await nextFrame();
const isWebKit =
/AppleWebKit/.test(window.navigator.userAgent) &&
!/Chrome/.test(window.navigator.userAgent);
const snapshot = (await a11ySnapshot({})) as unknown as DescribedNode & {
children: DescribedNode[];
};
// WebKit в настоящее время не связывает элемент с `aria-describedby`
// с атрибутом host в дереве доступности. Временно предоставим ему обходной путь.
const describedNode = findAccessibilityNode(
snapshot,
(node) =>
node.name === name && (node.description === description || isWebKit)
);
expect(describedNode, `node not in: ${JSON.stringify(snapshot, null, ' ')}`).to.not.be.null;
if (isWebKit) {
// Повторно тестируем WebKit без обходного пути, ожидая, что тест завершится неудачно.
// Таким образом, мы будем уведомлены, когда результаты снова станут ожидаемыми.
const iOSNode = findAccessibilityNode(
snapshot,
(node) => node.name === name && node.description === description
);
expect(iOSNode).to.be.null;
}
};
Вы увидите, что в этом тесте используется пользовательский агент для проверки WebKit, и тест проходит, когда описание не связано с <input>
, а браузер — это WebKit. Для того чтобы понимать, когда или если эта реальность изменится в будущем, тест затем выполняет проверку ожидания в обратном порядке для WebKit, чтобы при сбое в тесте можно было выявить проблему и удалить временное решение.
В других сценариях тестирования WebKit связывает содержимое описания без проблем, поэтому обращайте внимание на результаты и сочетайте их с ручным тестированием при установке базовых параметров, которые вы хотите защитить от регрессий. Я также видел подобные различия в интерпретации ролей (role
) определённых паттернов различными браузерами, поэтому важно учитывать дерево, которое создаётся вашим содержимым, при выборе подходящего паттерна тестирования.
Нативные события клавиатуры во время тестирования
Спасибо, Playwright!
Когда вы убедитесь, что предоставляемый вами интерфейс удобен для пользователей скринридеров, следующим сегментом пользователей, для которых нужно обеспечить доступность, будут пользователи клавиатуры.
Независимо от того, нужно ли управлять скринридером при работе с вашим содержимым или поддерживать другие сценарии, важно убедиться, что к вашему содержимому можно получить доступ с клавиатуры ожидаемым образом. Это может оказаться сложной задачей, так как тестирование событий клавиатуры на уровне модульных тестов зачастую оказывается намного сложнее, чем кажется. Тем не менее, как только вы создадите надёжный способ тестирования, используемые техники могут быть полезны и в других областях, например, в пользовательских интерфейсах, содержащих элементы вроде <input>
, с которыми традиционно взаимодействуют все пользователи через клавиатуру.
Синтетические события клавиатуры могут стать хорошей отправной точкой в этой области:
const keyboardEvent = (
code: string,
eventDetails = {},
eventName = 'keydown'
): KeyboardEvent => {
return new KeyboardEvent(eventName, {
...eventDetails,
bubbles: true,
composed: true,
cancelable: true,
code,
key: code,
});
};
Особенно когда тестируемая функциональность также полностью синтетическая, например, что-то, что вы добавили в свой элемент <input>, синтетические события могут быть весьма полезны. Однако, если вы стремитесь протестировать более сложные взаимодействия с клавиатурой, включая различные фазы нажатия клавиши, возможно, вам потребуется использовать библиотеку тестирования или фреймворк для успешного управления этой сложностью.
Когда вы переходите от прямого тестирования вашего кода к проверке того, как он должен работать в связке с браузером, где он выполняется, синтетические события начинают демонстрировать свои ограничения.
Это можно заметить, если попытаться использовать синтетическое событие для изменения значения элемента <input>
. Чтобы полностью воспроизвести такие взаимодействия, приходится накладывать всё больше синтетических событий поверх императивных команд для элемента <input>
, что делает процесс довольно хрупким. Если добавить ещё один шаг, например, тестирование взаимодействий при нажатии на Tab
, такой подход полностью перестаёт быть жизнеспособным.
Нативное событие клавиатуры не только охватывает все фазы нажатия клавиши, которые трудно воспроизвести надёжно, но и позволяет браузеру самостоятельно распознавать взаимодействие с клавиатурой и реагировать с функциональностью, которая выходит за рамки вашего кода. Это означает, что событие должно исходить непосредственно от браузера. Здесь на помощь приходят такие инструменты, как Playwright, которые поддерживают нативные взаимодействия с клавиатурой. Снова отметим, что @web/test-runner предоставляет доступ к этим API во время модульного тестирования через интерфейс Commands. Использование этого позволяет нам расположить элементы <input>
до и после тестируемого кода, и убедиться, что взаимодействия с клавишами Tab
и Shift + Tab
работают должным образом. Пример кода может выглядеть следующим образом:
import { sendKeys } from '@web/test-runner-commands';
// ...
it('is part of the tab order', async () => {
const el = await fixture<HTMLDivElement>(`
<div>
<label for="input">${label}</label>
<input id="input" aria-describedby="description" />
<div id="description">${description}</div>
</div>
`);
const input = el.querySelector('input') as HTMLInputElement;
const beforeInput = document.createElement('input');
const afterInput = document.createElement('input');
el.insertAdjacentElement('beforebegin', beforeInput);
el.insertAdjacentElement('afterend', afterInput);
beforeInput.focus();
expect(document.activeElement === beforeInput, `activeElement: ${document.activeElement}`).to.be.true;
await sendKeys({
press: 'Tab',
});
expect(document.activeElement === input, `activeElement: ${document.activeElement}`).to.be.true;
await sendKeys({
press: 'Tab',
});
expect(document.activeElement === afterInput, `activeElement: ${document.activeElement}`).to.be.true;
await sendKeys({
press: 'Shift+Tab',
});
expect(document.activeElement === input, `activeElement: ${document.activeElement}`).to.be.true;
await sendKeys({
press: 'Shift+Tab',
});
expect(document.activeElement === beforeInput, `activeElement: ${document.activeElement}`).to.be.true;
beforeInput.remove();
afterInput.remove();
});
В этом примере тест состоит из трёх элементов <input>
и сначала устанавливает фокус на первый, а затем использует события клавиш Tab
и Shift + Tab
для навигации между ними. Это может показаться тестированием кода, который вам не принадлежит, и в случае всех нативных элементов <input>
в одном дереве DOM вы будете правы. Однако, когда вступают в игру границы теневого DOM, становится гораздо важнее подтвердить, как пользователь клавиатуры может взаимодействовать с элементами, которые вы создаёте.
Как это сделать?
Существует множество способов структурировать эту реализацию элемента ввода с использованием пользовательских элементов и теневого DOM. Кроме того, вы можете комбинировать эти подходы в различных контекстах, чтобы адаптировать их «под ваши нужды» — будь то библиотека или продукт. С этого момента мы начнём изучать, как именно это сделать, рассмотрев более «чистые» реализации техник Wrapper, Decorator, Emitter, Outside-in и Snowflakes; а также то, как эти техники или их комбинации используются в проектах участников панельной дискуссии.
Выделение (Factoring) из исходного HTML
Мы уже видели исходный HTML, с которым будем работать, вот он:
<div>
<label for="input">Label</label>
<input id="input" aria-describedby="description" />
<div id="description">Description</div>
</div>
Посмотрите демо на webcomponents.dev.
Клонируйте код из репозитория на GitHub.
Ниже я привёл пять разных способов структурирования этого исходного HTML в пользовательские элементы. Однако это лишь небольшой выбор из доступных подходов. Для каждого из них мы рассмотрим пользовательские элементы, которые необходимо создать, чтобы использовать этот подход; как эти элементы изменяют использование HTML и какие изменения или дополнения могут понадобиться в нашем наборе тестов для поддержки этих решений. Я также добавлю ссылки на примеры всех или некоторых из этих техник в работах моих коллег, таких как Carbon Web Components, Lion, SAP и Vaadin, а также в моих собственных работах в Spectrum Web Components.
Wrapper
Смотрите демо на webcomponents.dev.
Клонируйте код из репозитория на GitHub.
Эта техника называется «обёрткой» (Wrapper), потому что, по сути, всё, что мы делаем, — это оборачиваем ранее созданный доступный HTML в пользовательский элемент:
<testing-a11y>
<label for="input">Label</label>
<input id="input" aria-describedby="description" />
<div id="description">Description</div>
</testing-a11y>
И всё, готово! Можно использовать.
Элемент <testing-a11y>
полагается на нативную доступность исходного HTML, с которого мы начали, а затем инкапсулирует повторно используемую функциональность, которую вы захотите включить в пользовательский элемент ввода, в родительский элемент-обёртку.
Однако, сам по себе этот подход возлагает большую ответственность на разработчика, использующего этот компонент, чтобы он полностью обеспечил выполнение всех условий доступности, заложенных в исходный HTML.
Я полагаю, что именно это требование к потребителям привело к тому, что другие участники панельной дискуссии решили не использовать эту технику. Но вы всегда можете связаться с ними и их командами, чтобы узнать больше.
Если вам нравится гибкость этого паттерна, но вы хотите снизить нагрузку на пользователей, обратите внимание на следующий паттерн.
Decorator
Смотрите демо на webcomponents.dev.
Клонируйте код из репозитория на GitHub.
<testing-a11y>
<label>Label</label>
<input />
<div>Description</div>
</testing-a11y>
Шаблон «Декоратор» (Decorator) заимствует подход обёртки (Wrapper) и, как следует из его названия, дополняет предоставленный HTML необходимыми атрибутами, чтобы сделать паттерн доступным.
Когда вы декорируете (добавляете или изменяете атрибуты) HTML, который передаётся в пользовательский элемент через слот, важно помнить, что владелец этого кода (приложение или компонент выше) может иметь свои ожидания относительно состояния этого DOM, с которыми лучше не вмешиваться. В этом случае наш элемент <testing-a11y>
в данном случае будет добавлять только те ID, которые необходимы для выполнения условий доступности, если такие ID ещё не указаны для соответствующих элементов. С учётом того, что необходимые aria-атрибуты могут уже иметь существующие связи, элемент добавляет эти атрибуты в список ссылок по ID вместо того, чтобы назначать им только новые декорированные идентификаторы. Этот подход полезен в ряде контекстов и может быть реализован с помощью следующих вспомогательных методов:
export function conditionAttributeWithoutId(
el: HTMLElement,
attribute: string,
ids: string[]
): void {
const ariaDescribedby = el.getAttribute(attribute);
let descriptors = ariaDescribedby ? ariaDescribedby.split(/\s+/) : [];
descriptors = descriptors.filter(
(descriptor) => !ids.find((id) => descriptor === id)
);
if (descriptors.length) {
el.setAttribute(attribute, descriptors.join(' '));
} else {
el.removeAttribute(attribute);
}
}
export function conditionAttributeWithId(
el: HTMLElement,
attribute: string,
id: string | string[]
): () => void {
const ids = Array.isArray(id) ? id : [id];
const ariaDescribedby = el.getAttribute(attribute);
const descriptors = ariaDescribedby ? ariaDescribedby.split(/\s+/) : [];
const hadIds = ids.every((currentId) => descriptors.indexOf(currentId) > -1);
if (hadIds) return function noop() {};
descriptors.push(...ids);
el.setAttribute(attribute, descriptors.join(' '));
return () => conditionAttributeWithoutId(el, attribute, ids);
}
Элемент может использовать метод conditionAttributeWithId
, а затем кэшировать возвращаемый метод conditionAttributeWithoutId
, чтобы позже выполнить очистку, не беспокоясь о перезаписи или удалении значений, важных для родительского контекста.
Кроме того, это довольно наивный пример декорирования DOM, который предполагает, что первый элемент <input>
, переданный через слот, является тем самым, который нужно декорировать, и то же самое касается первого элемента <label>
. Все остальные элементы, которые не являются <input>
или <label>
, предназначены для описания ввода.
Однако этот шаблон не гарантирует, что он принимает или отображает только те элементы <input>
или <label>
, которые ожидаются. Эти элементы могут добавлять содержимое в дерево доступности, которое в данный момент не управляется. Любая готовая к продакшену реализация этого паттерна должна включать дополнительную валидацию, чтобы это предотвратить. Если такая степень гибкости и валидации кажется вам неудобной, обратите внимание на следующий паттерн, который более строго управляет содержимым, предоставляемым пользовательским элементом.
Emitter
Смотрите демо на webcomponents.dev.
Клонируйте код из репозитория на GitHub.
<testing-a11y
label="Label"
description="Description"
></testing-a11y>
Усиленная версия шаблона Декоратор называется «Эмиттер» (Emitter). Как вы видите в примере HTML выше, разработчику, использующему компонент, больше не нужно структурировать собственный HTML для передачи в элемент <testing-a11y>
через слот. Шаблон Эмиттер полагается на атрибуты для предоставления доступного содержимого, который он должен отобразить, а затем генерирует доступный HTML на основе этих данных. Этот подход очень похож на паттерны, которые вы могли видеть в контексте JSX и других подходах к созданию компонентов пользовательского интерфейса. Главное отличие в том, что доступный HTML будет сгенерирован внутри элемента <testing-a11y>
, а не в месте, обозначенном вызовом функции <TestingA11y>
в JSX.
Decorator Pattern Plus
На пересечении паттернов «Эмиттер» и «Декоратор» находится расширенный шаблон «Декоратор Плюс», о котором я писал ранее. Сложно поверить, что ему уже больше трёх лет, но он по-прежнему отлично справляется с задачей введения паттерна, который мог бы стать шестым в этой статье. Объедините концепции этого паттерна с тем, что мы уже рассмотрели выше — как в контексте тестирования, так и в отношении связи элементов <input>
с меткой и описанием, — и вы, возможно, найдёте подходящий паттерн доступности для своего следующего пользовательского элемента ввода.
Примеры использования в проектах участников панельной дискуссии
Lion
Библиотека Lion использует форму паттерна Decorator Pattern Plus, позволяя либо генерировать DOM на основе предоставленных атрибутов или свойств, либо принимать содержимое, предназначенное для различных задач, через слот в элемент <lion-input>
.
<lion-input
label="Label"
help-text="Description"
></lion-input>
<!-- OR -->
<lion-input>
<div slot="label">Label</div>
<input slot="input" />
<div slot="help-text">Description</div>
</lion-input>
Это реализуется с помощью их FormControlMixin, который делает декорирование или генерацию light DOM содержимого удобным и единообразным для всей библиотеки.
Vaadin Web Components
Учитывая, что в рамках панельной дискуссии упоминалось влияние Lion на эту библиотеку, неудивительно, что команда Vaadin также использует форму Decorator Pattern Plus. Здесь вы также можете создать элемент <vaadin-text-field>
на основе атрибутов/свойств или переданного через слот содержимого.
<vaadin-text-field
label="Label"
helper-text="Description"
></vaadin-text-field>
<!-- OR -->
<vaadin-text-field>
<div slot="label">Label</div>
<input slot="input" />
<div slot="helper">Description</div>
</vaadin-text-field>
Здесь Vaadin использует паттерн реактивного контроллера, популяризированный командой Lit, чтобы управлять различными аспектами данного паттерна. Контент метки, описания, а также сам элемент <input>
обрабатываются так, чтобы их можно было легко повторно использовать в других частях библиотеки.
В обоих случаях разработчик, использующий пользовательские элементы формы, получает возможность выбирать, что именно предоставлять и где это делать, при этом сохраняя корректное связывание контента с деревом доступности. Это предоставляет разработчику гибкость при использовании пользовательских элементов.
Outside-in
Смотрите демо на webcomponents.dev.
Клонируйте код из репозитория на GitHub.
<testing-a11y>
<div slot="label">Label</div>
<div slot="description">Description</div>
</testing-a11y>
В этом подходе содержимое, важное для доступности элемента, находится как снаружи, так и внутри нашего элемента <testing-a11y>
. Внутри потребители этого элемента по умолчанию получают элемент <input>
, а снаружи контент направляется в слоты для label
и description
, чтобы его можно было правильно связать с элементом <input>
. Как и в случае с подходом эмиттера, это позволяет разработчику сосредоточиться на предоставлении контента, который он хочет использовать, в то время как элемент <testing-a11y>
управляет всеми связями, связанными с доступностью.
Этот паттерн делает ещё один шаг вперёд, не изменяя DOM-контексты, которыми он не владеет. Это гарантирует, что технологии рендеринга, применяемые на уровне родительского приложения или элемента, не будут вмешиваться в пользовательский интерфейс, который предоставляет наш пользовательский элемент.
Ссылки по ID НЕ проходят через границы теневого DOM
Это первая техника, которую мы рассмотрели, где содержимое, важное для обеспечения доступности паттерна, отделено границами теневого DOM. В связи с этим обратите внимание, что мы больше не указываем ID непосредственно на элементах, содержащих текст метки и описания. Это связано с тем, что ссылки по ID, созданные с помощью атрибута for
на элементе <label>
и атрибута aria-describedby
на элементе <input>
, НЕ проходят через границы теневого DOM. Чтобы обойти это ограничение, мы оборачиваем элементы <slot>
, через которые мы передаём контент из light DOM в теневой DOM, в элементы, содержащие эти ссылки. Контент, переданный в пользовательский элемент таким образом, будет привязан к этим обёрточным элементам, когда браузер создаёт дерево доступности из этого DOM для передачи скринридеру, чётко предоставляя содержимое интерфейса пользователям.
Проекты участников панельной дискуссии, в которых была использована эта техника
Carbon Web Components
Мы можем увидеть полную реализацию паттерна «Outside-in» в элементе <bx-input>
библиотеки Carbon Web Components, включая дополнительные слоты для содержимого, которые не были рассмотрены в этой статье.
<bx-input>
<div slot="label-text">Label</div>
<div slot="helper-text">Description</div>
</bx-input>
Это позволяет <bx-input>
в полной мере использовать связи доступности, созданные паттерном «Outside-in» для содержимого label-text. Однако при более внимательном рассмотрении вы заметите, что содержимое helper-text и validity-message в данный момент не связан с элементом <input>
.
Spectrum Web Components
Чтобы привязать описание к элементам формы, включая элемент <sp-textfield>
в библиотеке Spectrum Web Components, используется слот help-text
.
<sp-textfield>
<div slot="help-text">Description</div>
</sp-textfield>
Этот подход очень близок к описанному выше паттерну и расширяется с использованием техники под названием Stacked Slots (слои слотов), которая позволяет легко управлять несколькими элементами описания на основе валидности элемента <sp-textfield>
. Даже спустя всё это время, я считаю, что паттерны, связанные с использованием слотов и обеспечением доступности через теневой DOM, требуют ещё большого изучения!
UI5 Web Components
Вместе с визуальным решением, которое отображает дополнительное содержимое об элементе <input>
в в виде всплывающего окна ("popover"), элемент <ui5-input>
из UI5 Web Components использует слот valueStateMessage
, похожий на этот паттерн. Обратите внимание, что для отображения контента, предоставленного таким образом, должен быть установлен атрибут value-state
. Этот атрибут принимает значения Error
, Information
и Warning
для отображения содержимого с различными уровнями визуальной серьёзности.
<ui5-input value-state="Information">
<div slot="valueStateMessage">Description</div>
</ui5-input>
Однако для реализации содержимого через всплывающее окно требуется дополнительная механика. По умолчанию текст, добавленный в слот valueStateMessage
, дублируется в shadow root элемента <ui5-input>
и связывается с <input>
через вычисляемый атрибут aria-describedby
для скринридеров. Когда элемент <ui5-input>
находится в фокусе, любой контент из слота valueStateMessage
, будет скопирован в всплывающее окно для визуального отображения.
Snowflakes
Смотрите демо на webcomponents.dev.
Клонируйте код из репозитория на GitHub.
<div>
<testing-a11y-label for="input">Label</testing-a11y-label>
<testing-a11y-input id="input"></testing-a11y-input>
<testing-a11y-help-text for="input">Description</testing-a11y-help-text>
</div>
Все хотят быть уникальными, все хотят быть особенными, и иногда пользоватеские элементы «чувствуют» то же самое. Чтобы поддержать их в этом стремлении, мы опишем, как может выглядеть полностью кастомная реализация каждого из элементов, представленных в исходном HTML.
<testing-a11y-label>
заменяет нативный элемент<label>
и берёт на себя ответственность не только за перенаправление фокуса, как мы подтвердили в тестовом коде. Кроме того, он включает собственный атрибутfor
, который должен быть реализован с использованием кастомного JavaScript.<testing-a11y-input>
заменяет нативный элемент<input>
и упрощает структуру, больше не требуя использования атрибутаaria-describedby
.<testing-a11y-help-text>
помогает уточнить анонимную природу элемента<div>
, который мы ранее использовали для этого содержимого, и также включает собственный атрибутfor
. Мы рассмотрим, как отсутствие атрибутаaria-description
в платформе усложняет управление этим атрибутом for по сравнению с элементом<testing-a11y-label>
.
Имитируя нативный элемент
Одной из ключевых характеристик паттерна Snowflake является создание пользовательских элементов, которые имитируют нативное поведение существующих HTML-элементов, вместо их непосредственного использования, декорирования или расширения (что, скорее всего, невозможно без полифилов в Safari).
Это означает, что вам нужно учитывать возможности, которые нативные элементы предоставляют «по умолчанию». Это можно увидеть на примере использования атрибута for
в наших пользовательских элементах метки и текста подсказки, описанных выше. Оба элемента <testing-a11y-label>
и <testing-a11y-help-text>
должны дублировать ссылку по ID, устанавливаемую нативными элементами <label>
через атрибут for
. В этом паттерне атрибут for
указывает на элемент, который может быть реальным полем формы. Мы рассмотрим код, поддерживающий эту возможность. Однако, учитывая, что наш элемент <testing-a11y-input>
инкапсулирует своё поле формы внутри теневого DOM, нам также потребуется подготовить механизм для поддержания связи между двумя элементами, разделёнными границей теневого DOM.
Обработка атрибута "for"
Одним из преимуществ пользовательских элементов для разработчиков являются методы жизненного цикла, которые позволяют реагировать на изменения в элементах, происходящие на уровне браузера.
Два из таких методов — observedAttributes
и attributeChangedCallback
, которые позволяют отслеживать изменения атрибутов. С их помощью можно легко реагировать на изменения атрибута for
в наших пользовательских элементах метки и текста подсказки, чтобы гарантировать их корректную связь с элементами, на которые они ссылаются. Давайте подробнее рассмотрим, как это реализовано в <testing-a11y-label>
:
async resolveForElement() {
// Уборка (очистка) при изменении значения `for` с одного ID на другой.
if (this.conditionLabel) this.conditionLabel();
if (this.conditionLabelledby) this.conditionLabelledby();
if (!this.for) {
delete this.forElement;
return;
}
// [1] Разрешение элемента, на который ссылается ID, указанный в `for`. Это разрешение выполняется в дереве DOM, в котором существует элемент `<testing-a11y-label>`, поэтому целевой элемент должен существовать в этом же дереве.
const parent = this.getRootNode() as HTMLElement;
const target = parent.querySelector(`#${this.for}`) as LitElement & { focusElement: HTMLElement };
if (!target) {
return;
}
if (target.localName.search('-') > 0) {
await customElements.whenDefined(target.localName);
}
if (typeof target.updateComplete !== 'undefined') {
await target.updateComplete;
}
// [2] Нормализация целевого элемента как хост-элемента или элемента, доступного через свойство `focusElement` на этом хосте (для ссылок через границы теневого DOM).
this.forElement = target.focusElement || target;
if (this.forElement) {
const targetParent = this.forElement.getRootNode() as HTMLElement;
if (targetParent === parent) {
// [3a] Применение `aria-labelledby` для элементов в одном дереве DOM.
this.conditionLabelledby = conditionAttributeWithId(this.forElement, 'aria-labelledby', this.id);
} else {
// [3b] Применение `aria-label` для элементов, разделённых границами теневого DOM.
this.forElement.setAttribute('aria-label', this.labelText);
this.conditionLabel = () => this.forElement?.removeAttribute('aria-label');
}
}
}
Разбирая комментарии в коде:
По соображениям производительности этот код требует, чтобы связаные элементы находились в одном дереве DOM. Однако, если требования вашего приложения позволяют, это ограничение можно ослабить. Как видно из пункта 5b, уже существует поддержка связывания содержимого через границы теневого DOM. Поэтому вы можете либо подниматься вверх по дереву к документу, либо использовать альтернативные способы для определения
forElement
(например, принимая ссылку на элемент в области видимости JavaScript). При любом подходе важно быть готовым связать этот элемент с помощью данного кода.Выбор между
target.focusElement || target
для определенияforElement
ограничивает этот подход нативными элементами форм и пользоватескими элементами, которые реализуют эту технику. Это может показаться недостатком, но такая реализация тесно перекликается с разрабатываемой спецификацией Cross-root Aria Delegation, которая широко поддерживается разными браузерами.Здесь мы разделяем поддержку элементов в одном дереве DOM и тех, которые разделены границами shadow DOM. Это гарантирует, что наша концепция доступности продолжит работать, даже если нативные ссылки по ID не могут преодолеть этот разрыв.
Когда мы пытаемся применить этот же паттерн к <testing-a11y-help-text>
, мы быстро обнаруживаем, что атрибут aria-description
отсутствует. Из-за этого связывание текста подсказки через границы теневого DOM становится немного сложнее:
const proxy = document.createElement('span');
proxy.id = 'complex-non-reusable-id';
proxy.hidden = true;
proxy.textContent = this.labelText;
this.forElement.insertAdjacentElement('afterend', proxy);
const conditionDescribedby = conditionAttributeWithId(this.forElement, 'aria-describedby', 'complex-non-reusable-id');
this.conditionDescribedby = () => {
proxy.remove();
conditionDescribedby();
}
Здесь мы создаём прокси-элемент, который добавляется в дерево DOM на другой стороне теневой границы и связывается с текстом подсказки, предоставляемым для нашего элемента формы. Это означает, что <testing-a11y-help-text>
будет добавлять DOM в область рендеринга, которой он не управляет. Важно учитывать ограничения и возможные риски такого подхода при выборе дальнейших шагов. Однако, даже если разделение теневыми границами присутствует, если элементы по обе стороны разрыва принадлежат вам (или одной библиотеке), такие реалии можно легко учесть, и дерево доступности, сформированное из вашего содержимого, останется стабильным и надёжным.
Наблюдение за текстовым содержимым
При работе через границы теневого DOM для управления связями меток или описаний одной из дополнительных задач становится обеспечение актуальности текстового содержимого, применяемого к элементу формы через эту границу. Наши элементы должны наблюдать за изменениями в содержимом, чтобы выполнить эту задачу.
public connectedCallback(): void {
super.connectedCallback();
if (!this.observer) {
this.observer = new MutationObserver(() => this.resolveForElement());
}
this.observer.observe(this, { characterData: true, subtree: true, childList: true });
}
public disconnectedCallback(): void {
this.observer.disconnect();
super.disconnectedCallback();
}
private observer!: MutationObserver;
Конфигурация { characterData: true, subtree: true, childList: true }
гарантирует, что observer будет срабатывать на все изменения значения el.textContent
. Когда содержимое меняется, его нужно перенести через границу shadow DOM в другое дерево DOM, чтобы дерево доступности могло быть построено с ожидаемыми отношениями.
Проекты участников панельной дискуссии, в которой была использована эта техника
Spectrum Web Components
Этот паттерн используется специально для элемента <sp-field-label>
в библиотеке Spectrum Web Components для передачи содержимого метки элементам <sp-textfield>
при завершении интерфейса <input>
, который мы обсуждали ранее.
<div>
<sp-field-label for="input">Label</sp-field-label>
<sp-textfield id="input"></sp-textfield>
</div>
В библиотеке Spectrum Web Components элемент <sp-field-label>
почти дословно использует описанные выше подходы. Это позволяет интегрировать его с другими нативными элементами форм или пользовательскими элементами, которые предоставляют свойство focusElement для целенаправленного доступа к определённым дочерним элементам в теневом DOM.
UI5 Web Components
Аналогичным образом элемент <ui5-label>
в UI5 Web Components также использует эту технику в определённой степени.
<div>
<ui5-label id="label" for="input">Label</ui5-label>
<ui5-input id="input" accessible-name-ref="label"></ui5-input>
</div>
В этом случае управление атрибутом for
встроено в элемент <ui5-label>
а связь между элементом формы и элементом метки реализована с помощью атрибута accessible-name-ref
в рамках утилиты AriaLabelHelper. Это ещё один пример того, как мы лишь поверхностно коснулись способов обеспечения доступности пользовательского интерфейса с использованием теневых DOM, рассмотрев лишь несколько техник, включённых в эту статью.
Больше актуальных навыков по автоматизации тестирования вы можете получить в рамках практических онлайн-курсов от экспертов отрасли. Также приглашаем на открытые уроки: