Как создать доступный компонент текстового поля в React и TypeScript

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

В этой статье мы собираемся реализовать компонент TextField с использованием React и TypeScript, который имеет следующие функции:

  1. Элемент label и элемент input
  2. Edit button рядом с меткой управляет режимом редактирования ввода. Мы называем это режимом саморедактирования.
  3. Нажатие на Изменить button изменит input на редактируемый и автоматически сфокусирует внимание на поле ввода. После завершения редактирования фокус возвращается к ранее сфокусированному элементу — кнопке «Редактировать».
  4. Когда он находится в режиме редактирования самостоятельного редактирования, вы можете отменить или сохранить отредактированное значение.
  5. Он должен быть доступным, с поддержкой чтения с экрана и навигацией с помощью клавиатуры.

Пример дизайна пользовательского интерфейса этого компонента показан ниже:

Давайте начнем с создания компонента, не так ли?

Создание компонента TextField

Из приведенного выше макета мы начнем с базовой реализации компонента, включая поле input и поле label.

export function TextField () {
 return (
  <div className="textfield--wrapper">
    <div className="textfield--header">
       <label></label>
    </div>
   <input />
  </div>
 )
}

Поскольку мы хотим, чтобы этот компонент TextField вел себя аналогично полю input, мы позаботимся о том, чтобы он получил следующие свойства:

  1. id — уникальный идентификатор, который мы используем для идентификации поля input.
  2. label — текст метки для отображения
  3. placeholder — заполнитель для ввода. Это должно быть необязательно
  4. Хорошие атрибуты и события любых других входных данных

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

import { InputHTMLAttributes } from "react";

interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
    label: string;
}

В приведенном выше коде TypeScript наш TextFieldProps наследует атрибуты, определенные для HTMLInputElement, с дополнительным полем stringlabel. Теперь давайте сопоставим наши реквизиты с обозначенными местами, такими как label, id и другие входные реквизиты. Нам нужно убедиться, что label описывает наше поле input, используя htmlFor (вместо for в простом HTML), как показано ниже:

export function TextField ({ id, label, ...props }: TextFieldProps) {
 return (
  <div className="textfield--wrapper">
    <div className="textfield--header">
        <label htmlFor={id}>{label}</label>
    </div>
   <input id={id} {...props} className="textfield--input"/>
  </div>
 )
}

И мы также добавили немного CSS, чтобы наш компонент выглядел организованно:

.textfield--wrapper {
    display: flex;
    flex-direction: column;
    gap: 5px;
}

.textfield--header {
    display: flex;
    align-items: center;
    justify-content: space-between;
}

.textfield--input {
    padding: 10px;
    border-radius: 5px;
    font-size: 1rem;
}

Наш компонент TextField готов к основному использованию с приведенным выше кодом CSS в качестве поля ввода. Далее мы добавим режим редактирования для управления редактируемым режимом поля ввода.

Добавление режима управления саморедактированием

Во-первых, мы создадим локальное состояние editMode, используя useState, которое получает начальное значение false следующим образом:

const [editMode, setEditMode] = useState(false)

Затем мы используем это состояние editMode, чтобы контролировать, хотим ли мы разрешить нашему input быть read-only.

<input id={id} {...props} readOnly={!editMode} className="textfield--input" />

Теперь давайте добавим несколько действий с кнопками, чтобы позволить пользователю управлять состоянием editMode, не так ли?

Управление режимом редактирования компонента TextField

Из нашего первоначального дизайна для управления editMode со стороны клиента нам нужно отобразить следующие действия:

  • Редактировать — включить режим редактирования. Это видно, когда editMode равно false.
  • Отмена — отображается, когда editMode равно true, и позволяет пользователю вернуться в режим только для чтения без сохранения.
  • Сохранить — отображается, когда editMode равно false. Это позволяет пользователю запускать внешнюю функцию onSave и сохраняет новое отредактированное текстовое значение.

Звучит довольно просто. Мы модифицируем заголовок компонента TextField, включив в него три кнопки действий, при этом Save/Cancel появляется только тогда, когда editMode равно true, и Edit в противном случае:

<div className="textfield--header">
        <label htmlFor={id}>{label}</label>
        { editMode ? (
            <div className="textfield--header-actions">
                <button className="textfield--header-action">Cancel</button>
                <button className="textfield--header-action">Save</button>
            </div>
        ) : (     
            <button className="textfield--header-action">Edit</button>
        )}
    </div>

Теперь мы переключаем editMode всякий раз, когда пользователь нажимает кнопку «Редактировать» и кнопку «Сохранить/Отмена» соответственно, определяя следующие функции:

const closeEditMode = () => {
    setEditMode(false)
}

const openEditMode = () => {
    setEditMode(true)
}

const onEditHandler = () => {
    closeEditMode();
}

Затем мы привязываем эти функции к событиям onClick соответствующих кнопок следующим образом:

{ editMode ? (
    <div className="textfield--header-actions">
        <button onClick={closeEditMode} className="textfield--header-action">Cancel</button>
        <button onClick={onEditHandler} className="textfield--header-action">Save</button>
    </div>
) : (     
    <button onClick={openEditMode}>Edit</button>
)}

Наш компонент TextField теперь ведет себя следующим образом:

В следующем разделе будет рассмотрена более продвинутая функция специальной поддержки — фокусируемость.

Где мой фокус?

Доступность касается не только средств чтения с экрана. Также речь идет о поддержке навигации для пользователя, в которой фокус играет важную роль. Фокус позволяет пользователю с любой альтернативной навигацией (например, с помощью клавиатуры) определить свое местоположение на странице, помогая ему перемещаться по сложной странице.

На этом этапе мы добавляем функцию для переключения режима редактирования ввода. Мы позаботимся о том, чтобы при включенном режиме редактирования браузер автоматически фокусировался на поле ввода. А когда режим редактирования выключен, браузер вернет фокус на кнопку «Редактировать».

Фокусировка на вводе при переключении в режим редактирования

Чтобы изменить фокус с кнопки на поле ввода при включенном режиме редактирования, мы используем хуки useRef() и useEffect().

Сначала мы создадим ссылочную переменную inputRef и назначим ее атрибуту ref поля input следующим образом:

const inputRef = useRef<HTMLInputElement>(null);

//other logic

return (
    <div className="textfield--wrapper">
        <div className="textfield--header">
            <!--...-->
        </div>
       <input id={id} {...props} readOnly={!editMode} ref={inputRef} className="textfield--input"/>
    </div>
)

Затем мы используем useEffect() для фокусировки на элементе input всякий раз, когда editMode равен true.

useEffect(() => {
    if (!editMode) return;

    inputRef?.current?.focus()
}, [editMode])

Вот и все. Теперь у нас есть фокус ввода, когда пользователь переключается в режим редактирования.

Однако у нас все еще есть одна проблема. Обратите внимание, что когда вы нажимаете кнопку «Сохранить», вы теряете фокус на кнопке «Редактировать/Сохранить» после обновления компонента. За кулисами React уничтожил предыдущий элемент button для сохранения и заменил его другим элементом button, что привело к отсутствию фокуса.

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

Мы решим эту задачу дальше.

Возвращение фокуса на кнопку редактирования

Чтобы решить эту проблему с возвратом фокуса, мы сначала изменим HTML-структуру компонента, чтобы действия были следующими:

<div className="textfield--header-actions">
{editMode && (
    <button onClick={closeEditMode} className="textfield--header-action">Cancel</button>
)}
    <button 
        onClick={editMode ? onEditHandler : openEditMode} 
        className="textfield--header-action"
    >
        {editMode ? 'Save' : 'Edit'}
    </button>
</div>

Таким образом, всякий раз, когда мы нажимаем «Сохранить», браузер будет сохранять фокус на кнопке «Редактировать». Поскольку кнопка осталась прежней, ее метка и действие привязки onClick изменились.

Однако нам все еще нужно вернуть фокус на Edit при нажатии кнопки Cancel. Для таких сценариев мы сохраняем ссылку на кнопку «Изменить» с помощью useRef следующим образом:

const editBtnRef = useRef<HTMLButtonElement>(null)

Затем мы прикрепляем его к целевой кнопке, как в приведенном ниже коде:

<div>
{editMode && (
    <button onClick={closeEditMode} className="textfield--header-action">Cancel</button>
)}
    <button 
        className="textfield--header-action"
        ref={editBtnRef} 
        onClick={editMode ? onEditHandler : openEditMode}>
        {editMode ? 'Save' : 'Edit'}
    </button>
</div>

В функции closeEditMode мы переориентируемся на целевую кнопку, если editMode равно false, как показано ниже:

const closeEditMode = () => {
    setEditMode(false);
    editBtnRef?.current?.focus();
}

Большой. Теперь наш компонент включен с поддержкой режима полного фокуса:

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

Обеспечение доступности кнопок со значками с помощью атрибутов ARIA

Чтобы наш TextField выглядел как макет дизайна, мы заменим кнопки Сохранить/Редактировать/Отмена следующими значками — компоненты SaveIcon, EditIcon и CancelIcon соответственно. Каждый из них является компонентом SVG React (вы можете загрузить их через Иконки, но вы можете использовать любые другие генераторы иконок.

{editMode && (
    <button onClick={closeEditMode} className="textfield--header-action">
        <CloseIcon  />
    </button>
)}
<button
    className="textfield--header-action"
    ref={editBtnRef} 
    onClick={editMode ? onEditHandler : openEditMode}
>                  
    {editMode ? <SaveIcon /> : <EditIcon />}
</button>

Поскольку средство чтения с экрана не сможет прочитать кнопку со значком из-за отсутствия описательной метки, мы используем aria-label, чтобы предоставить читаемую метку кнопки со значком для средств чтения с экрана в дополнение к встроенному атрибуту title, поскольку показано ниже:

{editMode && (
    <button 
        className="textfield--header-action"
        onClick={closeEditMode} 
        aria-label="Cancel"  
        title="Cancel"
    >
        <CloseIcon  />
    </button>
)}
<button aria-label="Edit" title="Edit"
    className="textfield--header-action"
    ref={editBtnRef} 
    onClick={editMode ? onEditHandler : openEditMode}
    aria-label={editMode ? 'Save' : 'Edit'}
>                  
    {editMode ? <SaveIcon /> : <EditIcon />}
</button>

В отличие от некоторых атрибутов ARIA, атрибут title создан для HTML и поддерживается всеми браузерами. Вам нужно title, чтобы отображать всплывающую подсказку с кратким описанием целевой кнопки при наведении курсора, чтобы пользователю было легче понять, о чем кнопка. Нам также нужно установить aria-hidden на true для значка, чтобы программа чтения с экрана пропустила чтение значка, как показано ниже:

{editMode && (
    <button 
        className="textfield--header-action"
        onClick={closeEditMode} 
        aria-label="Cancel"  
        title="Cancel"
    >
        <CloseIcon aria-hidden="true" />
    </button>
)}
<button 
    className="textfield--header-action"
    ref={editBtnRef} 
    onClick={editMode ? closeEditMode : openEditMode}
    aria-label={editMode ? 'Save' : 'Edit'}
    title={editMode ? 'Save' : 'Edit'}
>                  
    {editMode ? (
        <SaveIcon aria-hidden="true" />
    ) : (
        <EditIcon aria-hidden="true" />
    )}
</button>

На следующем снимке экрана показано, как программа чтения с экрана захватывает кнопки.

https://res.cloudinary.com/mayashavin/video/upload/q_auto,f_auto/v1675154060/navigation_textfield.mp4

На снимке экрана выше обратите внимание, что в фокусе клавиатуры для любой из кнопок нет всплывающей подсказки, в отличие от наведения. Некоторым пользователям может быть трудно понять, для чего нужна кнопка, без всплывающей подсказки или заголовка. Один из подходов к обработке таких случаев заключается в использовании псевдоэлемента after класса textfield — header-action, когда целевой элемент находится в focus:

.textfield--header-action[title]:focus::after {
    content: attr(title);
    background-color: black;
    color: white;
    padding: 5px;
    margin-top: -2.3em;
    position: absolute;
    max-width: 200px;
    border-radius: 5px;
    z-index: 1;
}

В приведенном выше коде мы берем значение атрибута title с помощью attr(), назначаем его свойству content и добавляем другие правила CSS для позиционирования и украшения всплывающей подсказки. Теперь в режиме фокусировки наши кнопки будут иметь небольшую всплывающую подсказку, как показано на следующем снимке экрана:

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

Указание элемента управления для поля ввода с помощью aria-controls

Мы должны использовать aria-controls, чтобы указать управление этими кнопками в отношении видимости/состояния поля ввода с помощью его id, и завершить взаимодействие с пользователем следующим образом:

{editMode && (
    <button 
        className="textfield--header-action"
        onClick={closeEditMode} 
        aria-label="Cancel" 
        title="Cancel" 
        aria-controls={id}
    >
        <CloseIcon aria-hidden="true" />
    </button>
)}
    <button 
        ref={editBtnRef} 
        onClick={editMode ? onEditHandler : openEditMode}
        aria-label={editMode ? 'Save' : 'Edit'}
        title={editMode ? 'Save' : 'Edit'}
        className="textfield--header-action"
        aria-controls={id}
    >                  
        {editMode ? (
            <SaveIcon aria-hidden="true" />
        ) : (
            <EditIcon aria-hidden="true" />
        )}
    </button>

А средство чтения с экрана подберет нужную информацию и соответствующим образом объявит ее нашим пользователям.

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

Сохранение изменений ввода

Чтобы разрешить внешнее обновление входных значений, мы можем изменить TextFieldProps так, чтобы он принимал асинхронную функцию onSave, которая получает входное значение и возвращает Promise следующим образом:

interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
    label: String;
    onSave?: (value: any) => Promise<void>
}

Затем мы модифицируем onEditHandler, чтобы вызвать onSave с текущим значением input, как показано в следующем коде:

const onEditHandler = async () => {
    const currentValue = inputRef?.current?.value;

    const onSavePromise = await onSave?.(currentValue);

    closeEditMode();

    return onSavePromise;
}

Вот и все. Наш компонент завершен и будет отображать следующий пользовательский интерфейс для режима без редактирования:

И для режима редактирования:

Еще одна рекомендация — создать компонент, отображающий скрытый текст программы чтения с экрана, и использовать его с aria-describedby для предоставления более описательного контекста помимо обычного arial-label.

Кроме того, вы можете добавить onCancel или любые другие реквизиты, чтобы разрешить дополнительные настройки компонента или изменить реализацию, чтобы она соответствовала вашему внешнему виду, а также добавить к метке дополнительные реквизиты, такие как значок звездочки для метки требуемого поля и т. д. воображение разыгралось ;).

Полный рабочий код вы можете найти здесь.

Краткое содержание

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

Компонент TextField в этой статье неуправляем, так как его состояние доступно для редактирования. Если мы хотим использовать его в форме, где нам нужно управлять режимом редактирования извне, мы должны немного изменить логику компонента. В следующей статье будет рассмотрено, как превратить элемент в управляемый и доступный TextField.

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

Понравился этот пост или нашел его полезным? Оставайтесь с нами, чтобы узнать больше.

Первоначально опубликовано на https://mayashavin.com.