Тестирование доступности с помощью Shadow Roots

Недавно у меня была возможность обсудить трудности, уроки и победы при разработке Spectrum Web Components вместе с коллегами-разработчиками пользовательских элементов из команд IBM, ING, SAP и Vaadin. Если вы пропустили прямую трансляцию, посмотрите запись! Один из участников дискуссии, Ари Гилмор (Ari Gilmore), сделал замечательный вывод о том, что разработчикам, подобным нам, не хватает материалов для чтения, из которых можно было бы почерпнуть информацию для создания надежных практик доступности в пространстве веб-компонентов. Учитывая это, я подумал, что было бы неплохо взять некоторые из абстрактных концепций, которые мы обсуждали на дискуссии, и поделиться некоторыми реальными примерами рабочего и тестируемого кода. Надеюсь, это может лучше поддержать следующего разработчика (разработчиков), желающего воплотить в жизнь высококачественную, доступную, дизайнерскую систему для своей команды с помощью веб-компонентов.

Чтобы поддержать этот разговор, я приведу в жизнь шаблон элемента ввода с доступной маркировкой и текстом справки. По предложению Томаса Оллмера и команды ING, первым примером будет реализация без теневого DOM с соответствующим тестированием. Имея общую базу данных о том, как работает HTML и тестирование, мы рассмотрим несколько различных примеров обеспечения взаимосвязи между элементом ввода, элементом метки и элементом текста подсказки с помощью пользовательских элементов и теневого DOM. Мы поговорим о том, как можно смешивать и сочетать эти подходы и как некоторые из них соответствуют или поддерживают различные разрабатываемые и черновые спецификации для того, чтобы сделать этот процесс еще менее трудоемким.

Некоторые особенно актуальные темы, которые мы обсуждали во время дискуссии, я рассмотрю в этой статье:

  • использование механизма тестирования доступности axe-core
  • видение и понимание дерева доступности
  • использование родных клавиатурных взаимодействий во время тестирования
  • как ссылки на ID не проходят через теневую границу
  • способы имитации ссылок на родные элементы

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

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

  • стилизация контента
  • объединение форм
  • управление состоянием
  • валидация

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


Оглавление

  • Начиная с HTML
  • Что и как тестировать
    • axe-core
    • Дерево доступности
    • Нативные события клавиатуры во время тестирования
  • Как мы должны его создавать?
    • Факторинг из необработанного HTML
    • Обертка
    • Декоратор
    • Эмиттер
      • Паттерн декоратора плюс
      • Проекты участников дискуссии, использующие эту технику
    • Outside-in
      • Ссылки на идентификаторы НЕ проходят через границы тени
      • Проекты участников группы, использующие эту технику
    • Снежинки
      • Притворяется родным элементом
        • Реагирование на атрибут «for»
        • Наблюдение за содержанием текста
      • Проекты участников дискуссии, использующие эту технику
  • В память
  • В следующей жизни…

Отказ от ответственности
Прежде чем мы начнем, как я уже говорил в ходе дискуссии, я бы не назвал себя специалистом по доступности. Я понимаю, что доступность является важной частью предоставления продуктов людям, и стремлюсь к тому, чтобы инструменты, которые я использую для этого, со временем становились все более и более доступными. В сообществе я работаю с умными, неравнодушными людьми, такими как те, к которым я присоединился на панели, чтобы найти новые и лучшие способы делать вещи. В 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!

Первая остановка в любом тестировании доступности (или, возможно, в любом тестировании пользовательского интерфейса) должна быть на тестах, которые вы можете получить «бесплатно». Для нашего случая это может обеспечить механизм тестирования доступности axe-core, упакованный в Chai a11y Axe и поставляемый в @open-wc/testing. Хотя вы могли заметить, что я цитирую ужасно маленький процент проблем, которые может выявить автоматизированное тестирование, я рад услышать, что Deque (создатели axe-core) недавно изучили эту ситуацию и считают, что 57,38% проблем доступности могут быть обнаружены с помощью автоматизации, так что это будет большим первым шагом в подтверждении доступности шаблонов, которые вы предоставляете.

Более того, этот большой первый шаг на самом деле очень маленький. Посмотрите приведенный ниже код для подтверждения доступности DOM-фиксатора с помощью axe-core через Chai a11y axe:

// Has the side effect of binding Chai A11y aXe to `expect`
import { fixture, expect } from '@open-wc/testing';

// ...

it('passes the aXe-core audit', async () => {
    const el = await fixture<HTMLDivElement>(`
<div>
    <label for="input">Label</label>
    <input id="input" aria-describedby="description" />
    <div id="description">Description</div>
</div>
`);

    // Asynchronously tests the accessibility of the supplied content
    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, в сочетании с различными идентификационными ссылками и атрибутами 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 doesn't currently associate the `aria-describedby` element to the attribute
    // host in the accessibility tree. Give it an escape hatch for now.
    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) {
        // Retest WebKit without the escape hatch, expecting it to fail.
        // This way we get notified when the results are as expected, again.
        const iOSNode = findAccessibilityNode(
            snapshot,
            (node) => node.name === name && node.description === description
        );
        expect(iOSNode).to.be.null;
    }
};
Вход в полноэкранный режим Выход из полноэкранного режима

Вы увидите, что этот тест использует агент пользователя для сравнения на WebKit и затем позволяет тесту пройти, если описание не связано с <input> и браузер — WebKit. Чтобы помочь понять, когда/если эта реальность изменится в будущем, тест запускает ожидание в обратном порядке для WebKit, чтобы в нашем тесте произошел сбой и обходной путь можно было удалить.

В других сценариях тестирования WebKit будет ассоциировать содержимое описания без проблем, поэтому обратите внимание на свои результаты и используйте их в паре с ручным тестированием при определении базовых значений, от которых вы хотите защититься от регрессии. Я также наблюдал подобные различия в кросс-браузерном понимании роли определенных паттернов, поэтому будьте внимательны к дереву, которое создает ваш контент, когда решаете, какой паттерн тестирования применить.

События родной клавиатуры во время тестирования

Спасибо, 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>, пока весь процесс не станет довольно хрупким. Сделайте еще один шаг и сделайте ожидания при нажатии клавиши Tab, и подход полностью развалится.

Событие родной клавиатуры не просто имеет все фазы нажатия клавиши, которые трудно надежно синтезировать, браузер сам распознает взаимодействие с родной клавиатурой и отвечает функциональностью, выходящей за рамки того, что есть в вашем коде. Это означает, что событие должно исходить из самого браузера. Именно здесь на помощь приходят такие инструменты, как Playwright, которые обеспечивают поддержку нативных клавиатурных взаимодействий. Опять же, @web/test-runner дает вам доступ к этим API во время модульного тестирования через интерфейс команд. Используя это, мы можем разместить элементы <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, а также то, как некоторые из проектов участников дискуссии используют их или их комбинации.

Факторинг на основе необработанного 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.

Обертка

Посмотрите демо-версию на webcomponents.dev.

Клонируйте код на GitHub.

Эта техника называется «обертка», потому что на самом деле все, что мы делаем, это оборачиваем наш ранее доступный 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, с которого мы начали. Я бы предположил, что это требование более высокого уровня к потребителям привело к тому, что другие участники дискуссии также не используют эту технику, но вы всегда можете обратиться к ним и их командам за дополнительной информацией.

Если вам нравится гибкость этого шаблона, но вы предпочитаете облегчить нагрузку на потребителей, обратите внимание на наш следующий шаблон.

Декоратор

Посмотрите демонстрацию на webcomponents.dev.

Клонируйте код на GitHub.

<testing-a11y>
    <label>Label</label>
    <input />
    <div>Description</div>
</testing-a11y>
Вход в полноэкранный режим Выход из полноэкранного режима

Шаблон декоратора берет шаблон обертки и, как следует из его названия, украшает предоставленный HTML необходимыми атрибутами, чтобы обеспечить доступность шаблона. При декорировании HTML, который вставляется в ваш пользовательский элемент извне, важно помнить, что владелец этого кода (приложение или компонент выше) может иметь ожидания относительно состояния этого DOM, в которое лучше не вмешиваться. Таким образом, наш элемент <testing-a11y> в данном случае будет применять идентификаторы, необходимые для выполнения нашего контракта доступности, только в том случае, если идентификаторы еще не доступны для данного элемента (элементов). Учитывая также возможность того, что любые необходимые атрибуты aria могут уже иметь примененные к ним ассоциации, элемент «обуславливает» эти атрибуты в списке ссылок на ID этих атрибутов, а не устанавливает их только на декорированные 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>
Вход в полноэкранный режим Выход из полноэкранного режима

Доведите шаблон декоратора до 11, и вы получите шаблон эмиттера. Как видно из примера HTML выше, потребляющему разработчику больше не нужно структурировать собственный HTML для вставки в элемент <testing-a11y>. Шаблон эмиттера полагается на атрибуты для предоставления доступного содержимого, которое он будет предоставлять, а затем на основе этих данных рендерит доступный HTML. Этот подход очень похож на паттерны, которые вы могли видеть в JSX-контекстах других подходов к компоновке пользовательского интерфейса. Основное отличие заключается в том, что доступный HTML будет отображаться внутри элемента <testing-a11y>, а не в позиции, обозначенной вызовом функции <TestingA11y> в JSX.

Паттерн декоратора плюс

На пересечении паттерна эмиттера и паттерна декоратора находится паттерн декоратора плюс, о котором я уже писал ранее. Безумно подумать, что ему уже более трех лет, но он по-прежнему отлично справляется со своей задачей, представляя то, что в противном случае стало бы шестым паттерном для этой статьи. Сопоставьте приведенные в ней концепции с концепциями выше, как в отношении тестирования, так и в отношении связи элементов <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, который делает оформление или выделение легкого содержимого DOM красивым и единообразным во всей их библиотеке.

Веб-компоненты Vaadin
Упомянув в рамках дискуссии, что 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, создаваемая атрибутом for на элементе <label> и атрибутом aria-describedby на элементе <input> НЕ проходит через границы тени. Чтобы избежать этого, мы обернули элементы <slot>, на которые мы проецируем содержимое из светлого DOM в наш теневой DOM, в элементы, которые содержат эти ссылки. Содержимое, спроецированное в пользовательский элемент таким образом, будет приписано этим оберточным элементам, когда браузер построит дерево доступности из этого DOM, чтобы передать его устройству чтения с экрана, четко доставляющему содержимое этого пользовательского интерфейса пользователям, которых они поддерживают.

Проекты участников дискуссии, использующие эту технику

Веб-компоненты Carbon
В элементе <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
Чтобы прикрепить содержимое описания к элементам формы, включая элемент <sp-textfield> в библиотеке Spectrum Web Components, появляется слот help-text.

<sp-textfield>
  <div slot="help-text">Description</div>
</sp-textfield>
Вход в полноэкранный режим Выход из полноэкранного режима

Это очень близко использует описанный выше шаблон и расширяется до техники, называемой Stacked Slots, которая позволяет вам легко управлять несколькими частями содержимого описания на основе валидности элемента <sp-textfield>. Даже спустя столько времени, я нахожу, что шаблоны, созданные вокруг содержимого с прорезями и обеспечения доступности содержимого с использованием теневых корней, имеют много интересного!

Веб-компоненты UI5
В сочетании с решением визуального дизайна, которое предоставляет дополнительное содержимое элемента <input> в виде «всплывающего окна», элемент <ui5-input> из UI5 Web Components использует слот valueStateMessage, подобный этому шаблону. Обратите внимание, что атрибут value-state должен быть установлен для отображения содержимого, предоставленного таким образом. Этот атрибут принимает значения Error, Information и Warning для отображения содержимого на различных уровнях визуальной серьезности.

<ui5-input value-state="Information">
  <div slot="valueStateMessage">Description</div>
</ui5-input>
Вход в полноэкранный режим Выход из полноэкранного режима

Однако, чтобы добиться доставки содержимого с помощью всплывающего окна, есть некоторые дополнительные механизмы, которые используются в этой реализации. По умолчанию текстовое содержимое, применяемое к тексту valueStateMessage, дублируется в корень тени элемента <ui5-input> и связывается с <input> через вычисляемый атрибут aria-describedby для устройств чтения с экрана. Когда элемент <ui5-input> фокусируется, любое содержимое, переданное в слот valueStateMessage, будет вовремя скопировано во всплывающее окно для визуального отображения.

Снежинки

Посмотрите демонстрацию на 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.

Притворяться родным элементом

Одна из ключевых характеристик паттерна «снежинка» заключается в том, что вы создаете пользовательские элементы, имитирующие поведение существующих HTML-элементов, а не используете их напрямую, украшаете их или расширяете (что вряд ли возможно без полифиллинга в Safari). Это означает, что вам нужно будет осознать возможности, которые вы получали «бесплатно» в этих родных элементах. Одна из них должна быть очевидна при использовании атрибута for в наших пользовательских элементах label и help text выше. И <testing-a11y-label>, и <testing-a11y-help-text> должны будут дублировать ID ссылку, установленную в родных элементах <label> этим атрибутом. В этом шаблоне атрибут for указывает на элемент, который может быть фактическим полем формы, и вы увидите код для поддержки этой возможности, но зная, что наш <testing-a11y-input> заключил свой элемент формы внутри своего теневого DOM, нам также нужно подготовить путь, чтобы сохранить связь содержимого между двумя элементами, разделенными теневой границей.

Реагируя на атрибут «for»

Часть возможностей, которые пользовательские элементы открывают разработчикам, заключается в методах жизненного цикла, с помощью которых они могут подключаться к изменениям наших элементов в браузере. Два из них — observedAttributes и attributeChangedCallback, которые позволяют нам наблюдать за изменением атрибутов. С их помощью мы можем легко реагировать на изменения атрибута for в наших пользовательских элементах label и help text, чтобы убедиться, что эти элементы должным образом связаны с элементом, на который ссылаются. Посмотрите подробнее, как мы это делаем в <testing-a11y-label>:

async resolveForElement() {
    // House keeping for when the value of `for` changes from one ID to another.
    if (this.conditionLabel) this.conditionLabel();
    if (this.conditionLabelledby) this.conditionLabelledby();
    if (!this.for) {
        delete this.forElement;
        return;
    }
    // [1] Resolution of the element referenced by the ID provided as `for`. This resolution happens in the DOM tree in which the `<testing-a11y-label>` element exists, so the referenced element will need to exist there as well.
    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] Noralization of the referenced element as the referenced host or an element available via the `focusElement` property on that host (for cross shadow boundary referencing).
    this.forElement = target.focusElement || target;
    if (this.forElement) {
        const targetParent = this.forElement.getRootNode() as HTMLElement;
        if (targetParent === parent) {
            // [3a] Application of `aria-labelledby` for elements in the same DOM tree.
            this.conditionLabelledby = conditionAttributeWithId(this.forElement, 'aria-labelledby', this.id);
        } else {
            // [3b] Application of `aria-label` for elements separated by shadow boundaries.
            this.forElement.setAttribute('aria-label', this.labelText);
            this.conditionLabel = () => this.forElement?.removeAttribute('aria-label');
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Смотрим конкретно на пронумерованные комментарии выше:

  1. По соображениям производительности этот код требует, чтобы ссылающиеся элементы находились в одном и том же DOM-дереве. Если это основано на требованиях вашего приложения, то это может быть чем-то, что вы можете смягчить. Как показано в 5b, уже существует поддержка ассоциирования содержимого через границы теней, поэтому независимо от того, поднимаетесь ли вы по разным деревьям к document или решаете разрешить forElement альтернативными средствами (возможно, принимая фактическую ссылку на элемент в области видимости JS), вы должны быть полностью готовы обозначить этот элемент с помощью этого кода.
  2. Выбор разрешения target.focusElement || target для forElement, вероятно, ограничивает этот подход собственными элементами формы и пользовательскими элементами формы, которые купились на эту технику, что можно считать неудачным. Тем не менее, он очень похож на спецификацию кросс-корневого делегирования Aria, которая в настоящее время находится в разработке и широко согласована с различными производителями браузеров.
  3. Здесь мы делаем ворота между поддерживающими элементами в одном и том же дереве DOM и элементами, разделенными теневыми границами. Это гарантирует, что наша история доступности будет продолжена, даже если родные ссылки ID не смогут преодолеть этот разрыв самостоятельно.

При попытке применить этот же шаблон к <testing-a11y-help-text> вы быстро обнаружите, что здесь нет атрибута aria-description. Из-за этого ассоциирование нашего текста справки через границы тени немного сложнее:

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 в область рендеринга, которая ему не принадлежит, и важно помнить об ограничениях и опасностях, связанных с этим, при выборе дальнейшего пути в этой области. Однако даже когда они разделены этой теневой границей, если вы (или один и тот же разработчик/библиотека) владеете элементами по обе стороны границы, эти реалии можно легко учесть, и дерево доступности, сформированное на основе вашего контента, будет стабильным и надежным.

Наблюдение за текстовым содержимым

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

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 } гарантирует, что наблюдатель будет срабатывать при всех изменениях значения el.textContent. Когда содержимое изменяется, его нужно переместить через теневую границу в другое дерево DOM, чтобы дерево доступности могло быть построено с ожидаемыми отношениями.

Проекты участников дискуссии, использующие эту технику

Веб-компоненты Spectrum
Этот паттерн используется специально для элемента <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
Аналогично, элемент <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> и разрешение от элемента формы к элементу label через атрибут accessible-name-ref как часть их утилиты AriaLabelHelper. Это еще один пример того, что мы лишь поверхностно изучаем возможности создания доступного пользовательского интерфейса с теневыми корнями, глядя на несколько методов, включенных в эту статью.

В память

Если вы до сих пор думали, что невозможно сделать теневой DOM доступным, спасибо, что остались здесь до сих пор, чтобы прояснить это недоразумение.

Если вы понимали, как можно сделать теневой DOM доступным, спасибо, что не остались в стороне, чтобы услышать о способах, которые я понимаю, чтобы сделать это.

Надеюсь, каждый получил пару дополнительных инструментов для того, чтобы сделать свой следующий веб-компонент или тот, который уже поставляется, еще более доступным. Но помните, что это лишь пара возможностей! Если у вас есть другие методы, которые вы знаете, используете или любите, пожалуйста, поделитесь ими в комментариях, чтобы я, или следующий разработчик, или сообщество в целом могли положить их в свой пояс инструментов.

А еще лучше, если вы видите, что я что-то упустил, что-то сделал не так или о чем-то хотите узнать больше, начните этот разговор. Единственный способ для всех нас стать лучше в создании доступных пользовательских интерфейсов — сделать это центральным элементом разговора о создании пользовательских интерфейсов. Делиться тем, что мы знаем, задавать вопросы о том, чего мы не знаем, и расширять границы во всех направлениях — большая часть этого процесса. Жду с нетерпением, чтобы увидеть это ниже!

В следующей жизни…

Как я уже говорил в самом начале, существует МНОГО концепций, связанных с поставкой полноценного веб-компонента, которые мы не рассмотрели в этом разговоре. В том числе:

  • стилизация контента
  • ассоциация форм
  • управление состоянием
  • валидация

Каждая из этих тем и многое другое могли бы стать отличной последующей статьей, развивающей вышеперечисленные паттерны в любом из этих направлений. Я не могу ничего обещать, но я сделаю все возможное… если вы заинтересованы в этом, я буду рад сотрудничать, поддерживать, подбадривать вас по мере того, как это будет происходить!

Кроме того, есть несколько более разнообразных и сложных паттернов для доступности веб-компонентов, в которых тоже было бы полезно покопаться. В частности, участники дискуссии по Vaadin затронули суперполезные паттерны combobox, которые в настоящее время начинают поставляться в различных продуктах других участников, что в настоящее время поднимает бэкграунд Spectrum Web Components. Обмен мыслями о том, как перенести этот опыт в веб с помощью пользовательских элементов и теневого DOM, может стать тем толчком, который необходим для завершения разработки этого паттерна.

Давайте продолжать говорить о доступности. Давайте продолжать делать наши пользовательские интерфейсы и компоненты доступными. Давайте вместе искать новые и лучшие паттерны для этого. Увидимся в следующий раз.

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *