React 中的無障礙性

在我們的最後一篇教程文章中,我們將重點關注 (雙關語) 無障礙性,包括 React 中的焦點管理,它可以提高可用性並減少鍵盤使用者和螢幕閱讀器使用者的困惑。

先決條件

熟悉核心 HTMLCSSJavaScript 語言,瞭解 終端/命令列

目標 學習如何在 React 中實現鍵盤無障礙性。

包括鍵盤使用者

到目前為止,我們已經實現了我們計劃實施的所有功能。使用者可以新增新任務,選中和取消選中任務,刪除任務或編輯任務名稱。此外,他們還可以按所有任務、活動任務或已完成任務來過濾任務列表。

或者,至少,他們可以用滑鼠執行所有這些操作。不幸的是,這些功能對鍵盤使用者來說不太友好。現在讓我們來探討一下。

探索鍵盤可用性問題

首先,點選我們應用程式頂部的輸入框,就像你要新增新任務一樣。你會看到該輸入框周圍有一個粗的虛線輪廓。這個輪廓是瀏覽器當前聚焦於此元素的視覺指示器。按 Tab 鍵,你會看到輪廓出現在輸入框下方的“新增”按鈕周圍。這表明瀏覽器的焦點已移動。

再按幾次 Tab 鍵,你會看到這個虛線焦點指示器在每個過濾器按鈕之間移動。繼續按,直到焦點指示器出現在第一個“編輯”按鈕周圍。按 Enter 鍵。

<Todo /> 元件將切換模板,就像我們設計的那樣,你會看到一個表單,讓我們可以編輯任務名稱。

但是我們的焦點指示器去哪裡了?

當我們在 <Todo /> 元件中切換模板時,我們會完全刪除舊模板中的元素,並用新模板中的元素替換它們。這意味著我們之前聚焦的元素不再存在,因此沒有視覺線索來表明瀏覽器的焦點在哪裡。這可能會讓各種使用者感到困惑,尤其是依賴鍵盤的使用者或使用輔助技術的使用者。

為了改善鍵盤使用者和輔助技術使用者的體驗,我們應該自己管理瀏覽器的焦點。

旁註:關於焦點指示器的說明

如果你用滑鼠點選“全部”、“活動”或“已完成”過濾器按鈕,不會看到可見的焦點指示器,但如果你用鍵盤上的 Tab 鍵在它們之間移動,則會看到。別擔心,你的程式碼沒有問題!

我們的 CSS 檔案使用 :focus-visible 偽類為焦點指示器提供自定義樣式,瀏覽器使用一組內部規則來確定何時將其顯示給使用者。通常,瀏覽器在響應鍵盤輸入時顯示焦點指示器,並且可能會在響應滑鼠輸入時顯示它。<button> 元素不會在響應滑鼠輸入時顯示焦點指示器,而 <input> 元素顯示。

:focus-visible 的行為比你可能更熟悉的舊的 :focus 偽類更具選擇性。:focus 在更多情況下顯示焦點指示器,你也可以使用它來代替或與 :focus-visible 結合使用,如果你願意。

在模板之間聚焦

當用戶將 <Todo /> 模板從檢視更改為編輯時,我們應該將焦點放在用於重新命名的 <input> 上;當他們從編輯更改回檢視時,我們應該將焦點移回“編輯”按鈕。

定位我們的元素

到目前為止,我們一直在編寫 JSX 元件,並讓 React 在幕後構建生成的 DOM。大多數時候,我們不需要定位 DOM 中的特定元素,因為我們可以使用 React 的狀態和屬性來控制渲染的內容。但是,為了管理焦點,我們需要能夠定位特定的 DOM 元素。

這就是 useRef() 鉤子的用武之地。

首先,更改 Todo.jsx 頂部匯入語句,使其包含 useRef

jsx
import { useRef, useState } from "react";

useRef() 建立一個具有單個屬性的物件:current。引用可以儲存我們希望它們儲存的任何值,我們以後可以查詢這些值。我們甚至可以儲存對 DOM 元素的引用,這正是我們將在本文中要做的。

接下來,在 Todo() 函式中的 useState() 鉤子下方建立兩個新的常量。每個都應該是一個引用——一個用於檢視模板中的“編輯”按鈕,另一個用於編輯模板中的編輯欄位。

jsx
const editFieldRef = useRef(null);
const editButtonRef = useRef(null);

這些引用預設值為 null,以表明它們在被附加到其 DOM 元素之前將為空。為了將它們附加到其元素,我們將向每個元素的 JSX 新增特殊的 ref 屬性,並將這些屬性的值設定為相應的 ref 物件。

更新編輯模板中的 <input>,使其看起來像這樣

jsx
<input
  id={props.id}
  className="todo-text"
  type="text"
  value={newName}
  onChange={handleChange}
  ref={editFieldRef}
/>

更新檢視模板中的“編輯”按鈕,使其看起來像這樣

jsx
<button
  type="button"
  className="btn"
  onClick={() => setEditing(true)}
  ref={editButtonRef}>
  Edit <span className="visually-hidden">{props.name}</span>
</button>

這樣做將用它們附加到的 DOM 元素的引用填充我們的 editFieldRefeditButtonRef,但只有在 React 渲染完元件之後。親自測試一下:在 Todo() 函式的主體中,在初始化 editButtonRef 的位置下方,新增以下程式碼行

jsx
console.log(editButtonRef.current);

你會看到,當元件第一次渲染時,editButtonRef.current 的值為 null,但如果你點選“編輯”按鈕,它會將 <input> 元素記錄到控制檯中。這是因為引用只有在元件渲染後才會被填充,而點選“編輯”按鈕會導致元件重新渲染。在繼續之前,請確保刪除此日誌。

注意:你的日誌將出現 6 次,因為我們的應用程式中有 3 個 <Todo /> 例項,並且 React 在開發過程中會渲染我們的元件兩次。

我們越來越接近了!為了利用我們新引用的元素,我們需要使用另一個 React 鉤子:useEffect()

實現 useEffect()

useEffect() 的名稱來源於它執行我們想要新增到渲染過程中的任何副作用,但這些副作用不能在主函式體內執行。useEffect() 在元件渲染後立即執行,這意味著我們在上一節中引用的 DOM 元素將可供我們使用。

再次更改 Todo.jsx 的匯入語句,新增 useEffect

jsx
import { useEffect, useRef, useState } from "react";

useEffect() 接受一個函式作為引數;此函式在元件渲染執行。為了演示這一點,將以下 useEffect() 呼叫放在 Todo() 函式主體中的 return 語句正上方,並將一個將“副作用”記錄到你的控制檯的函式傳遞給它

jsx
useEffect(() => {
  console.log("side effect");
});

為了說明主渲染過程和 useEffect() 內部執行的程式碼之間的區別,請新增另一個日誌——將此日誌放在之前的新增內容下方

jsx
console.log("main render");

現在,在你的瀏覽器中開啟應用程式。你應該在你的控制檯中看到兩條訊息,每條訊息重複多次。注意“主渲染”如何先記錄,而“副作用”如何後記錄,即使“副作用”日誌在程式碼中先出現。

main render                                     Todo.jsx
side effect                                     Todo.jsx

同樣,日誌以這種方式排序是因為 useEffect() 內部的程式碼在元件渲染執行。這需要一些時間來適應,在前進過程中請記住這一點。現在,刪除 console.log("主渲染"),我們將繼續執行我們的焦點管理。

聚焦於我們的編輯欄位

現在我們知道 useEffect() 鉤子有效了,我們可以用它來管理焦點。作為提醒,我們希望在切換到編輯模板時將焦點放在編輯欄位上。

更新你現有的 useEffect() 鉤子,使其看起來像這樣

jsx
useEffect(() => {
  if (isEditing) {
    editFieldRef.current.focus();
  }
}, [isEditing]);

這些更改使 React 在 isEditing 為真時讀取 editFieldRef 的當前值,並將瀏覽器焦點移動到該值。我們還將一個數組作為第二個引數傳遞給 useEffect()。這個陣列是 useEffect() 應該依賴的值列表。透過包含這些值,useEffect() 只有在這些值之一發生更改時才會執行。我們只希望在 isEditing 的值發生更改時更改焦點。

現在試試:使用 Tab 鍵導航到一個“編輯”按鈕,然後按 Enter 鍵。你應該看到 <Todo /> 元件切換到其編輯模板,並且瀏覽器焦點指示器應該出現在 <input> 元素周圍!

將焦點移回編輯按鈕

乍一看,讓 React 在儲存或取消編輯時將焦點移回我們的“編輯”按鈕似乎非常容易。我們當然可以向我們的 useEffect 新增一個條件,如果 isEditingfalse,則將焦點放在編輯按鈕上?現在讓我們試一下——更新你的 useEffect() 呼叫,使其如下所示

jsx
useEffect(() => {
  if (isEditing) {
    editFieldRef.current.focus();
  } else {
    editButtonRef.current.focus();
  }
}, [isEditing]);

這有點有效。如果你使用鍵盤觸發“編輯”按鈕(記住:用 Tab 鍵定位它,然後按 Enter 鍵),你會發現你的焦點會在開始和結束編輯時在“編輯”<input> 和“編輯”按鈕之間移動。但是,你可能已經注意到一個新問題——最後一個 <Todo /> 元件中的“編輯”按鈕在我們甚至與應用程式互動之前就立即獲得了焦點!

我們的 useEffect() 鉤子完全按照我們的設計執行:它在元件渲染後立即執行,看到 isEditingfalse,並使“編輯”按鈕獲得焦點。<Todo /> 有三個例項,最後一個渲染的例項的“編輯”按鈕獲得了焦點。

我們需要重構我們的方法,以便只有當 isEditing 的值從一個值更改為另一個值時,焦點才會更改。

更強大的焦點管理

為了滿足我們改進後的標準,我們需要知道的不僅僅是 isEditing 的值,還要知道何時更改了該值。為此,我們需要能夠讀取 isEditing 常量的先前值。使用虛擬碼,我們的邏輯應該類似於以下內容

jsx
if (wasNotEditingBefore && isEditingNow) {
  focusOnEditField();
} else if (wasEditingBefore && isNotEditingNow) {
  focusOnEditButton();
}

React 團隊討論了 獲取元件先前狀態的方法,並提供了一個我們可以用來完成此工作的示例鉤子。

輸入 usePrevious()

將以下程式碼貼上到 Todo.jsx 頂部,位於 Todo() 函式上方。

jsx
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

usePrevious() 是一個自定義鉤子,它跟蹤跨渲染的值。它

  1. 使用 useRef() 鉤子建立一個空 ref
  2. refcurrent 值返回給呼叫它的元件。
  3. 呼叫 useEffect() 並更新儲存在 ref.current 中的值,以響應呼叫元件的每次渲染。

useEffect() 的行為是此功能的關鍵。因為 ref.currentuseEffect() 呼叫中更新,所以它總是比元件主渲染週期中的任何值落後一步——因此得名 usePrevious()

使用 usePrevious()

現在我們可以定義一個 wasEditing 常量來跟蹤 isEditing 的前一個值;這是透過將 isEditing 作為引數呼叫 usePrevious 來實現的。將以下內容新增到 Todo() 中,位於 useRef 行下方

jsx
const wasEditing = usePrevious(isEditing);

你可以透過在此行下方新增一個控制檯日誌來檢視 usePrevious() 的行為

jsx
console.log(wasEditing);

在此日誌中,wasEditingcurrent 值將始終是 isEditing 的前一個值。點選“編輯”和“取消”按鈕幾次,觀察它的變化,然後在你準備好繼續時刪除此日誌。

有了這個 wasEditing 常量,我們可以更新 useEffect() 鉤子以實現我們之前討論的虛擬碼

jsx
useEffect(() => {
  if (!wasEditing && isEditing) {
    editFieldRef.current.focus();
  } else if (wasEditing && !isEditing) {
    editButtonRef.current.focus();
  }
}, [wasEditing, isEditing]);

請注意,useEffect() 的邏輯現在取決於 wasEditing,因此我們在依賴項陣列中提供它。

嘗試使用鍵盤在 <Todo /> 元件中啟用“編輯”和“取消”按鈕;你會看到瀏覽器的焦點指示器適當地移動,而不會出現我們本節開頭討論的問題。

當用戶刪除任務時聚焦

還有一個鍵盤體驗差距:當用戶從列表中刪除任務時,焦點消失。我們將遵循與之前更改類似的模式:我們將建立一個新的 ref,並利用我們的 usePrevious() 鉤子,以便我們可以在使用者刪除任務時將焦點放在列表標題上。

為什麼是列表標題?

有時,我們想要將焦點發送到的位置是顯而易見的:當我們切換 <Todo /> 模板時,我們有一個“返回”的原點——“編輯”按鈕。但是,在這種情況下,由於我們完全從 DOM 中刪除了元素,因此我們沒有可以返回的位置。下一個最佳位置是附近的一個直觀位置。列表標題是我們最好的選擇,因為它靠近使用者將刪除的列表項,並且將焦點放在它上面會告訴使用者還剩下多少任務。

建立我們的 ref

useRef()useEffect() 鉤子匯入到 App.jsx 中——你將在下面需要這兩個鉤子

jsx
import { useState, useRef, useEffect } from "react";

接下來,在 App() 函式中宣告一個新的 ref,就在 return 語句上方

jsx
const listHeadingRef = useRef(null);

準備標題

像我們 <h2> 這樣的標題元素通常不可聚焦。這不是問題——我們可以透過新增屬性 tabindex="-1" 來使任何元素以程式設計方式可聚焦。這意味著只能用 JavaScript 聚焦。你不能像對 <button><a> 元素那樣按下 Tab 來聚焦具有 -1 tabindex 的元素(這可以透過 tabindex="0" 來完成,但這在這種情況下不合適)。

讓我們將 tabindex 屬性(在 JSX 中寫為 tabIndex)新增到我們任務列表上方的標題,以及我們的 listHeadingRef

jsx
<h2 id="list-heading" tabIndex="-1" ref={listHeadingRef}>
  {headingText}
</h2>

注意:tabindex 屬性非常適合可訪問性邊緣情況,但你應該非常小心不要過度使用它。只有當你確定使元素可聚焦將對你的使用者有所幫助時,才將其應用於元素。在大多數情況下,你應該使用可以自然地獲取焦點的元素,例如按鈕、錨點和輸入。不負責任地使用 tabindex 會對鍵盤和螢幕閱讀器使用者產生非常負面的影響!

獲取上一個狀態

我們希望僅當用戶從他們的列表中刪除任務時,才將焦點放在與我們的 ref 關聯的元素(透過 ref 屬性)上。這將需要我們之前使用的 usePrevious() 鉤子。將其新增到 App.jsx 檔案的頂部,就在匯入語句下方

jsx
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

現在新增以下內容,就在 App() 函式中的 return 語句上方

jsx
const prevTaskLength = usePrevious(tasks.length);

在這裡,我們呼叫 usePrevious() 來跟蹤任務陣列的上一個長度。

注意:由於我們現在在兩個檔案中使用 usePrevious(),因此將其移動到單獨的檔案中、從該檔案匯出並將其匯入到需要它的位置可能更有效。在你完成之後,嘗試這樣做作為練習。

使用 useEffect() 來控制標題焦點

現在我們已經儲存了我們以前有多少個任務,我們可以設定一個 useEffect() 鉤子,以便在我們的任務數量發生變化時執行,如果我們現在的任務數量少於之前,則會將焦點放在標題上——也就是說,我們刪除了一個任務!

將以下內容新增到 App() 函式的正文中,就在你之前的新增內容下方

jsx
useEffect(() => {
  if (tasks.length < prevTaskLength) {
    listHeadingRef.current.focus();
  }
}, [tasks.length, prevTaskLength]);

我們只有在我們現在的任務數量少於之前時,才會嘗試將焦點放在我們的列表標題上。傳遞給此鉤子的依賴項確保它只會嘗試在任何一個值(當前任務數量或以前的任務數量)發生變化時重新執行。

現在,當你使用鍵盤刪除瀏覽器中的任務時,你會看到我們的虛線焦點輪廓出現在列表上方的標題周圍。

完成!

你剛剛完成了從頭開始構建 React 應用程式!恭喜!你在此處學習的技能將成為你在繼續使用 React 時構建的良好基礎。

大多數情況下,即使你所做的一切只是仔細思考元件及其狀態和屬性,你也可以成為 React 專案的有效貢獻者。記住始終編寫最佳的 HTML。

useRef()useEffect() 是比較高階的功能,你應該為自己使用它們而感到自豪!尋找練習它們的機會,因為這樣做將使你能夠為使用者建立包容性的體驗。記住:如果沒有它們,我們的應用程式對鍵盤使用者來說是不可訪問的!

注意:如果你需要將你的程式碼與我們的版本進行比較,你可以在我們的 todo-react 儲存庫 中找到示例 React 應用程式程式碼的完成版本。對於執行的即時版本,請參見 https://mdn.github.io/todo-react/

在最後一篇文章中,我們將向你提供一個 React 資源列表,你可以使用這些資源來進一步學習。