Как создать доступный компонент текстового поля в React и TypeScript
Написание доступных компонентов, как считают многие разработчики, является сложной задачей. Однако с соответствующей структурой и дизайном компонентов вы можете быстро добиться доступности, начиная с самых важных.
В этой статье мы собираемся реализовать компонент TextField
с использованием React и TypeScript, который имеет следующие функции:
- Элемент
label
и элементinput
- Edit
button
рядом с меткой управляет режимом редактирования ввода. Мы называем это режимом саморедактирования. - Нажатие на Изменить
button
изменитinput
на редактируемый и автоматически сфокусирует внимание на поле ввода. После завершения редактирования фокус возвращается к ранее сфокусированному элементу — кнопке «Редактировать». - Когда он находится в режиме редактирования самостоятельного редактирования, вы можете отменить или сохранить отредактированное значение.
- Он должен быть доступным, с поддержкой чтения с экрана и навигацией с помощью клавиатуры.
Пример дизайна пользовательского интерфейса этого компонента показан ниже:
Давайте начнем с создания компонента, не так ли?
Создание компонента TextField
Из приведенного выше макета мы начнем с базовой реализации компонента, включая поле input
и поле label
.
export function TextField () { return ( <div className="textfield--wrapper"> <div className="textfield--header"> <label></label> </div> <input /> </div> ) }
Поскольку мы хотим, чтобы этот компонент TextField
вел себя аналогично полю input
, мы позаботимся о том, чтобы он получил следующие свойства:
id
— уникальный идентификатор, который мы используем для идентификации поляinput
.label
— текст метки для отображенияplaceholder
— заполнитель для ввода. Это должно быть необязательно- Хорошие атрибуты и события любых других входных данных
Приведенные выше требования указывают на то, что интерфейс свойств нашего компонента будет выглядеть следующим образом:
import { InputHTMLAttributes } from "react"; interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> { label: string; }
В приведенном выше коде TypeScript наш TextFieldProps
наследует атрибуты, определенные для HTMLInputElement
, с дополнительным полем string
— label
. Теперь давайте сопоставим наши реквизиты с обозначенными местами, такими как 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>
На следующем снимке экрана показано, как программа чтения с экрана захватывает кнопки.
На снимке экрана выше обратите внимание, что в фокусе клавиатуры для любой из кнопок нет всплывающей подсказки, в отличие от наведения. Некоторым пользователям может быть трудно понять, для чего нужна кнопка, без всплывающей подсказки или заголовка. Один из подходов к обработке таких случаев заключается в использовании псевдоэлемента 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.