Communitygithub.com

junksidetm/darkside-toolkit

React 和 Next.js 的無障礙模式 — 語意化 HTML、ARIA 屬性、表單標籤、鍵盤導航、焦點管理與螢幕閱讀器支援。適用於建置或審查表單、模態框、下拉選單、工具提示或分頁標籤,修正無障礙 lint 或程式碼審查結果,以及配置鍵盤導航和焦點管理。

darkside-toolkit 是什麼?

此技能提供了一套針對 React 和 Next.js 的實用無障礙模式,專注於程式碼審查中最常被標記的問題:遺漏的表單標籤、不正確的 ARIA 使用、非語意化的互動元素,以及故障的鍵盤導航。它支援主流開發平台(如 VS Code、Cursor 和 Claude Code),並提供可直接套用的 TypeScript/JSX 程式碼範例,涵蓋表單無障礙(包括標籤連結、必填欄位和錯誤訊息)、語意化 HTML 替換方案、ARIA 屬性的正確使用(如 `aria-label`、`aria-describedby`、`aria-live` 和 `aria-expanded`)、自訂下拉選單的鍵盤導航、模態框的焦點管理(含焦點恢復)、圖片與圖示的替代文字,以及尊重使用者減少動畫設定的 `prefers-reduced-motion` 媒體查詢。此技能還包含常見反模式的警示,並在末尾提供一份開發人員在提交互動元件前應檢查的核對清單,確保符合 WCAG 準則。

相容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/junksidetm/darkside-toolkit/tree/HEAD/skills/frontend-a11y

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

預覽

來自技能 README

darkside-toolkit preview
darkside-toolkit preview
darkside-toolkit preview

說明文件

前端無障礙模式

適用於 React 和 Next.js 的實用無障礙模式。涵蓋程式碼審查中最常被標記的問題:遺漏的表單標籤、不正確的 ARIA 用法、非語意化的互動元素,以及故障的鍵盤導航。

啟用時機

  • 建置或審查表單元件(<input>、<select>、<textarea>)
  • 建立互動元素(模態框、下拉選單、工具提示、分頁標籤)
  • 使用帶有 onClick 的 <div> 或 <span>
  • 為任何元素添加 aria-* 屬性
  • 實作鍵盤導航或焦點管理
  • 收到來自程式碼審查工具(CodeRabbit、ESLint a11y)的無障礙反饋
  • 建置必須支援螢幕閱讀器的元件

表單無障礙

遺漏的 htmlFor / id 配對和斷開的錯誤訊息是程式碼審查中最常見的問題。

標籤連結

// 不良:標籤與輸入框無關聯 — 螢幕閱讀器無法將它們關聯起來
<label>Email</label>
<input type="email" />

// 良好:htmlFor 與 input id 相符
<label htmlFor="email">Email</label>
<input id="email" type="email" />

必填欄位

// 不良:僅視覺上的星號,對螢幕閱讀器沒有任何意義
<label htmlFor="email">Email *</label>
<input id="email" type="email" />

// 良好:required 啟用瀏覽器原生驗證;aria-required 向螢幕閱讀器發出信號
<label htmlFor="email">
  Email <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />

錯誤訊息

// 不良:錯誤訊息在視覺上存在,但未與輸入框連結
<input id="email" type="email" />
<span className="error">無效的電子郵件地址</span>

// 良好:aria-describedby 將輸入框與其錯誤訊息連結
// aria-invalid 向螢幕閱讀器發出無效狀態的信號
<input
  id="email"
  type="email"
  aria-describedby="email-error"
  aria-invalid={!!error}
/>
{error && (
  <span id="email-error" role="alert">
    {error}
  </span>
)}

完整的無障礙表單

interface LoginFormProps {
  onSubmit: (email: string, password: string) => void;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const newErrors: typeof errors = {};
    if (!email) newErrors.email = '需要填寫電子郵件';
    if (!password) newErrors.password = '需要填寫密碼';
    if (Object.keys(newErrors).length) {
      setErrors(newErrors);
      return;
    }
    onSubmit(email, password);
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="email">
          Email <span aria-hidden="true">*</span>
        </label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
          aria-required="true"
          aria-describedby={errors.email ? 'email-error' : undefined}
          aria-invalid={!!errors.email}
          autoComplete="email"
        />
        {errors.email && (
          <span id="email-error" role="alert">
            {errors.email}
          </span>
        )}
      </div>

      <div>
        <label htmlFor="password">
          Password <span aria-hidden="true">*</span>
        </label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={e => setPassword(e.target.value)}
          aria-required="true"
          aria-describedby={errors.password ? 'password-error' : undefined}
          aria-invalid={!!errors.password}
          autoComplete="current-password"
        />
        {errors.password && (
          <span id="password-error" role="alert">
            {errors.password}
          </span>
        )}
      </div>

      <button type="submit">登入</button>
    </form>
  );
}

語意化 HTML

使用符合意圖的元素。螢幕閱讀器和鍵盤使用者依賴原生的語意。

// 不良:div 沒有角色、沒有鍵盤支援、沒有可存取名稱
<div onClick={handleClick}>提交</div>

// 良好:button 可聚焦、可在 Enter/Space 觸發、宣告為「按鈕」
<button type="button" onClick={handleClick}>提交</button>
// 不良:非語意化的導航
<div onClick={() => navigate('/home')}>首頁</div>

// 良好:錨點支援右鍵、中鍵和鍵盤導航
<a href="/home">首頁</a>
// 不良:標題層級跳躍(從 h1 到 h4)
<h1>儀表板</h1>
<h4>近期活動</h4>

// 良好:連續的標題層級
<h1>儀表板</h1>
<h2>近期活動</h2>

ARIA 屬性

僅在原生 HTML 語意不足時使用 ARIA。錯誤的 ARIA 比沒有 ARIA 更糟。

aria-label 與 aria-labelledby

// aria-label:內聯字串標籤 — 在沒有可見標籤文字時使用
<button aria-label="關閉模態框">
  <XIcon />
</button>

// aria-labelledby:引用其他元素的文字 — 在存在可見標籤時使用
<section aria-labelledby="section-title">
  <h2 id="section-title">近期訂單</h2>
  {/* 內容 */}
</section>

aria-describedby

// 提供標籤之外的補充描述
<button
  aria-describedby="delete-warning"
  onClick={handleDelete}
> 刪除帳戶
</button>
<p id="delete-warning">此操作無法復原。</p>

動態內容的 aria-live

// 使用 aria-live 來宣告無需重新載入頁面即可更新的內容
// polite:等待使用者完成當前操作後再宣告
// assertive:立即中斷 — 僅在緊急錯誤時使用

export function StatusMessage({ message, isError }: { message: string; isError?: boolean }) {
  return (
    <div role="status" aria-live={isError ? 'assertive' : 'polite'} aria-atomic="true">
      {message}
    </div>
  );
}

aria-expanded 與 aria-controls

export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  const contentId = useId();

  return (
    <div>
      <button aria-expanded={isOpen} aria-controls={contentId} onClick={() => setIsOpen(prev => !prev)}>
        {title}
      </button>
      <div id={contentId} hidden={!isOpen}>
        {children}
      </div>
    </div>
  );
}

鍵盤導航

每個互動元素都必須可透過鍵盤單獨到達和操作。

自訂下拉選單

export function Dropdown({ options, onSelect }: { options: string[]; onSelect: (value: string) => void }) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const listId = useId();

  if (!options.length) return null;

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex(i => Math.min(i + 1, options.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex(i => Math.max(i - 1, 0));
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (isOpen) onSelect(options[activeIndex]);
        setIsOpen(prev => !prev);
        break;
      case 'Escape':
        setIsOpen(false);
        break;
    }
  };

  return (
    <div
      role="combobox"
      aria-expanded={isOpen}
      aria-haspopup="listbox"
      aria-controls={listId}
      tabIndex={0}
      onKeyDown={handleKeyDown}
      onClick={() => setIsOpen(prev => !prev)}
    >
      <span>{options[activeIndex]}</span>
      {isOpen && (
        <ul id={listId} role="listbox">
          {options.map((option, index) => (
            <li
              key={option}
              role="option"
              aria-selected={index === activeIndex}
              onClick={() => {
                onSelect(option);
                setIsOpen(false);
              }}
            >
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

焦點管理

當 UI 狀態改變時,焦點必須邏輯地移動 — 特別是對於模態框和路由切換。

模態框焦點恢復

此範例涵蓋初始焦點和恢復。若要實現完整的焦點循環(Tab/Shift+Tab 在模態框內循環),請使用像 focus-trap-react 這樣的函式庫,它可以處理像動態內容和巢狀入口元件這樣的邊界情況。

export function Modal({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // 儲存當前聚焦的元素並將焦點移入模態框
      previousFocusRef.current = document.activeElement as HTMLElement;
      modalRef.current?.focus();
    } else {
      // 將焦點恢復到打開模態框的元素
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} onKeyDown={e => e.key === 'Escape' && onClose()}>
      <h2 id="modal-title">{title}</h2>
      {children}
      <button onClick={onClose}>關閉</button>
    </div>
  );
}

圖片與圖示

// 不良:裝飾性圖示被宣告為未標籤的圖片
<img src="/icon.svg" />

// 良好:裝飾性圖片對螢幕閱讀器隱藏
<img src="/decoration.png" alt="" aria-hidden="true" />

// 良好:有意義的圖片搭配描述性替代文字
<img src="/chart.png" alt="一月到三月,月收入成長 23%" />

// 良好:帶有可存取標籤的圖示按鈕
<button aria-label="刪除項目">
  <TrashIcon aria-hidden="true" />
</button>

減少動畫

尊重使用者在作業系統設定中要求減少動畫的請求。

export function useReducedMotion(): boolean {
  const [prefersReduced, setPrefersReduced] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    setPrefersReduced(mq.matches);
    const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  return prefersReduced;
}

// 使用方式
export function AnimatedCard({ children }: { children: React.ReactNode }) {
  const reduceMotion = useReducedMotion();

  return (
    <div
      style={{
        transition: reduceMotion ? 'none' : 'transform 300ms ease'
      }}
    >
      {children}
    </div>
  );
}

反模式

// 不良:在非互動元素上使用 onClick,且無鍵盤支援
<div onClick={handleClick}>點我</div>

// 不良:在沒有角色的 div 上使用 aria-label
<div aria-label="導航">...</div>

// 不良:使用 placeholder 替代標籤
<input placeholder="輸入您的電子郵件" />

// 不良:正值的 tabIndex 會造成不可預測的 Tab 順序
<button tabIndex={3}>提交</button>

// 不良:在可聚焦元素上使用 aria-hidden — 鍵盤使用者會被卡住
<button aria-hidden="true">開啟</button>

// 不良:在沒有鍵盤處理程式的 div 上使用 role="button"
<div role="button" onClick={handleClick}>提交</div>
// 缺少:tabIndex={0}、onKeyDown(處理 Enter/Space)

核對清單

在提交任何互動元件進行審查之前:

  • 每個 <input>、<select> 和 <textarea> 都透過 htmlFor/id 連結了對應的 <label>
  • 錯誤訊息已使用 aria-describedby 連結,並標記為 role="alert"
  • 沒有在 <div> 或 <span> 上使用 onClick,除非搭配了 role、tabIndex 和 onKeyDown
  • 純圖示按鈕具有 aria-label
  • 裝飾性圖片使用了 alt="" 和 aria-hidden="true"
  • 模態框在關閉時恢復焦點(若要使用 Tab/Shift+Tab 實現完整的焦點循環,請使用像 focus-trap-react 這樣的函式庫)
  • 動態內容更新使用 aria-live
  • 動畫效果尊重 prefers-reduced-motion

相關技能

  • frontend-patterns — 通用 React 元件和狀態模式
  • design-system — 設計權杖和元件一致性
  • motion-foundations 和 motion-patterns:具無障礙考量的動畫模式

Individual skills in this repo

This repo contains 2 individual skills — each has its own dedicated page.

相關技能