跳到主要內容

FE Day 42 無障礙與響應式總檢

FE Day 42 無障礙與響應式總檢

執行需求:CPU 可跑。今天是「前端開發實戰:React 與 Next.js 全套」系列的第四十二天,貫穿專案「預約管理系統前端」進入上線前的最後一輪品質檢查。今天不寫新功能,只做四件事:第一,用 @axe-core/playwright 把 11 個頁面全部掃一遍,違規就讓 CI 失敗;第二,用 Playwright 走一遍純鍵盤流程(Skip Link、篩選面板、對話框焦點鎖定);第三,在 390、768、1200、1440 四種寬度各截一張圖,並自動檢查水平溢出與觸控目標大小;第四,把掃出來的違規一項一項修掉(對話框焦點、手機版篩選收合、響應式表格、色彩對比、減少動畫偏好)。今天所有資料都是虛構示範,沿用 Day 31–41 的 NEXT_PUBLIC_API_MODE=mock 離線模式,不連任何後端也能完整跑完檢查。

引言

無障礙(a11y)與響應式(responsive)是兩個最常在「上線前最後一週」才被想起來的主題。它們的共同點是:平時看不出問題,出問題時代價很高。一個沒有 label 的輸入框在桌機上看起來完全正常,但螢幕閱讀器使用者會聽到「編輯框」而不知道要填什麼;一個寫死的 320 像素側欄在 1440 寬的螢幕上像教科書,在 390 寬的手機上卻會把整個版面撐出水平捲軸。

貫穿專案的「預約管理系統」是個小型服務業線上預約平台(情境涵蓋攝影棚、健身教練、諮詢工作室,所有資料都是虛構示範)。Day 31–37 把 11 個頁面做完、Day 38 做完登入與角色導向、Day 39 做完效能調校、Day 40 做完測試金字塔、Day 41 部署到 Vercel。今天要做的,是把 Day 28 的 a11y 基礎與 Day 33 提到的「手機版篩選」收尾:把自動掃描、鍵盤走訪、多斷點檢視這三件事寫成可重複執行的測試,並且讓每一條都接進 Day 40 的 CI 流程。做完之後,「上線品質」不再靠人工憑感覺,而是有一組會自動擋下回歸的檢查。

今天的內容分四段:第一段把 Day 31–41 的共用設定再次統整;第二段說明 WCAG 2.2 AA 的判準、行動優先的斷點策略、以及觸控目標與焦點可見性的具體數字;第三段實作三支 Playwright 檢查與四個修正;第四段整合進 CI 並跑一次完整結果。讀完之後你會拿到一份可以照著跑的檢查腳本、一份「掃到這類違規要改哪裡」的對照表,以及一份能放進 PR 描述裡的檢查紀錄。

貫穿專案共用設定(Day 31–44 沿用)

從 Day 31 開始的「預約管理系統前端」到今天 Day 42,整個 stack 維持一致設定,避免後續篇章互相矛盾:

  • 框架:Next.js 15(App Router、Server Components 預設)、React 19.x、TypeScript 5.9
  • 樣式:Tailwind CSS 4.x、CSS variables 主題、Day 32 的 components/ui/(Button、Input、Card、Badge、Dialog、Toast)
  • 後端:FastAPI 預約管理系統(Web 系列 Day 35–44 定義的 REST API);NEXT_PUBLIC_API_MODE=mock 時走 lib/mock/*.ts fixture
  • 認證:JWT access token(12 小時)、refresh token(14 天,httpOnly cookie);沿用 Day 38 的 AuthProvider
  • 角色:admin(管理者)、customer(客戶)
  • 路由:客戶端 /、/services、/services/[id]、/bookings、/bookings/new;認證 /login、/register;後台 /admin、/admin/bookings、/admin/services、/admin/services/[id]/slots,共 11 個頁面
  • 測試:Day 27 的 Playwright 1.5x、Day 40 的 Vitest 3.x 與 axe-core 4.x;今天把 a11y 與響應式檢查擴充成完整覆蓋
  • 設計 token:--color-brand-600: #4f46e5、--color-text-primary: #0f172a、--color-text-secondary: #475569、--color-surface: #ffffff、--color-danger-600: #e11d48

這份設定從 Day 31 到 Day 44 不變。今天的總檢是「在同一份設定上補檢查與修缺陷」,不改路由結構、不改 API 契約、不改設計系統的介面;唯一新增的是 styles/a11y.css 這個全域樣式檔、一支 useMediaQuery 鉤子,以及三支 Playwright 檢查。

原理解念:WCAG 2.2 AA 判準、行動優先斷點與觸控目標

無障礙的驗收標準是 WCAG 2.2,我們把目標鎖在 AA 等級。AA 是實務上最被廣泛要求的等級,它的具體要求散在幾十條成功準則裡,但對前端工程師來說可以收斂成六個可檢查的數字:一般文字對比至少 4.5:1、大字(18pt 或 14pt 粗體)至少 3:1、互動元件的觸控目標至少 24 × 24 CSS 像素且相鄰間距足夠(AA 級要求),而我們自家規範再往上一階、把主要操作訂在 44 × 44、焦點指示必須可見且對比至少 3:1、頁面必須宣告語言(lang="zh-Hant")、所有功能都必須能只用鍵盤完成。把標準翻譯成數字的好處是「可以寫成測試」:數字能斷言,形容詞不能。

響應式的核心觀念是「行動優先(mobile first)」。行動優先不是「先做手機版再做桌機版」的流程口號,而是 CSS 的撰寫順序:預設樣式就是手機版,再用 min-width 的媒體查詢往上加。這樣做有兩個好處:第一,手機使用者拿到的是最精簡的樣式,不必先下載桌機版規則再被覆蓋;第二,版面的「最小可行結構」先被確立,桌機版只是加強而不是重寫。我們的斷點沿用 Tailwind 4.x 的預設值,但刻意只挑四個在檢查中出現:390(現代手機)、768(平板直向)、1200(筆電)、1440(桌機)。檢查這四個寬度,是因為它們分別對應「單欄」「兩欄」「側欄加內容」「大留白」四種完全不同的版面行為。

觸控目標與焦點可見性是最容易被忽略的兩個細節,因為它們「不影響功能」。44 × 44 這個數字來自 WCAG 2.5.5 的建議與各大平台的設計規範(Apple 的 Human Interface Guidelines 與 Material Design 都落在這個區間)。少於這個尺寸,手指在移動中的公車上幾乎點不準,誤觸率會顯著上升。焦點可見性則是鍵盤使用者的生命線:outline: none 是前端最常見的錯誤,它讓鍵盤使用者完全失去「我現在在哪」。正確做法是用 :focus-visible,只在鍵盤操作時顯示焦點框,滑鼠點選不會出現,兼顧美觀與可及性。

最後是「減少動畫偏好」。前庭功能障礙、偏頭痛、眩暈症的使用者會被大幅位移與視差滾動觸發症狀,作業系統因此提供了 prefers-reduced-motion 偏好。前端要做的事很單純:偵測這個偏好、把動畫時長壓到近乎歸零、關掉平滑滾動。prefers-color-scheme 則處理另一個偏好:使用者選了深色模式,介面就該跟著換。今天會把這兩個偏好都用全域 CSS 處理掉,而不是散在幾十個元件裡各寫一次。

完整實作:全站掃描、鍵盤走訪、四斷點檢視與修正

第一步,安裝今天要用的檢查工具。@axe-core/playwright 是 Day 28 用過的老朋友,今天只補上 Playwright 的裝置描述子,讓我們能精準切換四種寬度。

# 安裝 Day 42 需要的檢查工具(2026 年 3 月主流版本)
pnpm add -D @axe-core/playwright@^4.10.0 @playwright/test@^1.5.0
# 只用 Chromium 就足以涵蓋 axe 與斷點檢查,CI 也最省時間
pnpm exec playwright install chromium

第二步,寫全站掃描。這一支測試的價值在於「把 11 個頁面變成 11 個會自動失敗的檢查」。我們只把 serious 與 critical 兩個等級當成阻擋條件,因為 minor 與 moderate 常常是主觀取捨(例如某個裝飾元素的對比),全部當成錯誤會讓 CI 永遠是紅的,反而讓團隊開始忽略它。

// tests/e2e/a11y.spec.ts
// 把 11 個頁面全部掃一遍;嚴重與重大違規就讓 CI 失敗。
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

const PAGES = [
  { path: '/', role: 'public' },
  { path: '/services', role: 'public' },
  { path: '/services/svc-001', role: 'public' },
  { path: '/login', role: 'public' },
  { path: '/register', role: 'public' },
  { path: '/bookings', role: 'customer' },
  { path: '/bookings/new?serviceId=svc-001', role: 'customer' },
  { path: '/admin', role: 'admin' },
  { path: '/admin/bookings', role: 'admin' },
  { path: '/admin/services', role: 'admin' },
  { path: '/admin/services/svc-001/slots', role: 'admin' },
];

const ACCOUNTS = {
  admin: { email: 'admin@example.com', password: 'admin-pass' },
  customer: { email: 'alice@example.com', password: 'alice-pass' },
};

async function loginAs(page, role) {
  await page.goto('/login');
  await page.getByLabel('Email').fill(ACCOUNTS[role].email);
  await page.getByLabel('密碼').fill(ACCOUNTS[role].password);
  await page.getByRole('button', { name: '登入' }).click();
  await expect(page.getByRole('main')).toBeVisible();
}

function formatViolations(violations) {
  return violations
    .map((v) => `${v.id}(${v.impact}):${v.help} → ${v.nodes[0]?.target.join(' ')}`)
    .join('\n');
}

for (const { path: pagePath, role } of PAGES) {
  test(`a11y ${pagePath}`, async ({ page }) => {
    if (role !== 'public') await loginAs(page, role);
    await page.goto(pagePath);
    // 等骨架畫面消失再掃,避免掃到載入中的骨架元素
    await expect(page.getByRole('main')).toBeVisible();

    const { violations } = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
      .analyze();

    const blocking = violations.filter((v) => v.impact === 'serious' || v.impact === 'critical');
    expect(blocking, formatViolations(blocking)).toEqual([]);
  });
}

第三步,寫純鍵盤流程。這支測試比 axe 更進一步:axe 能判斷「這個按鈕有沒有名字」,但判斷不了「Tab 的順序是不是合理」、「焦點有沒有被困在對話框裡」。這類問題只能靠真的按鍵盤來驗證。page.keyboard.press 送出的是瀏覽器層級的鍵盤事件,跟真人操作走同一條路徑。

// tests/e2e/keyboard.spec.ts
// 只用鍵盤完成 Skip Link、篩選面板、對話框三個關鍵互動。
import { test, expect } from '@playwright/test';

test('Skip Link 可用鍵盤直達主要內容', async ({ page }) => {
  await page.goto('/services');
  await page.keyboard.press('Tab');
  const skipLink = page.getByRole('link', { name: '跳至主要內容' });
  await expect(skipLink).toBeFocused();
  await page.keyboard.press('Enter');
  await expect(page.locator('#main-content')).toBeFocused();
});

test('篩選面板可用 Tab 與 Space 操作', async ({ page }) => {
  await page.goto('/services');
  const search = page.getByRole('searchbox', { name: '搜尋' });
  await search.focus();
  await page.keyboard.type('攝影');
  await page.keyboard.press('Tab');
  await expect(page.getByLabel('服務類型')).toBeFocused();
});

test('對話框開啟後焦點鎖在框內,Esc 可關閉並歸還焦點', async ({ page }) => {
  await page.goto('/bookings');
  const trigger = page.getByRole('button', { name: '取消預約' }).first();
  await trigger.click();

  const dialog = page.getByRole('dialog');
  await expect(dialog).toBeVisible();

  // 連續 Tab 六次,焦點必須一直留在對話框內
  for (let i = 0; i < 6; i += 1) await page.keyboard.press('Tab');
  const stillInside = await dialog.evaluate((el) => el.contains(document.activeElement));
  expect(stillInside).toBe(true);

  await page.keyboard.press('Escape');
  await expect(dialog).toBeHidden();
  await expect(trigger).toBeFocused();
});

第四步,寫四斷點檢視。這支測試做三件事:截圖存檔(人工複核用)、檢查水平溢出(自動化最有效的一條)、檢查主要操作的觸控目標。水平溢出用 scrollWidth 減 clientWidth 判斷,只要大於 0 就代表有東西被推出畫面——這是手機版最常見的災難,也是自動化最容易抓的一條。

// tests/e2e/responsive.spec.ts
// 四種寬度各截一張圖,並檢查水平溢出與觸控目標。
import { test, expect } from '@playwright/test';

const VIEWPORTS = [
  { name: 'mobile-390', width: 390, height: 844 },
  { name: 'tablet-768', width: 768, height: 1024 },
  { name: 'laptop-1200', width: 1200, height: 800 },
  { name: 'desktop-1440', width: 1440, height: 900 },
];

const PAGES = ['/services', '/bookings', '/admin/bookings'];

for (const viewport of VIEWPORTS) {
  for (const pagePath of PAGES) {
    test(`響應式 ${viewport.name} ${pagePath}`, async ({ page }) => {
      await page.setViewportSize({ width: viewport.width, height: viewport.height });
      await page.goto(pagePath);
      await page.waitForLoadState('networkidle');

      const label = `${viewport.name}${pagePath.replace(/\//g, '-')}`;
      await page.screenshot({ path: `tests/__screenshots__/${label}.png`, fullPage: true });

      // 檢查一:不能出現水平捲軸
      const overflow = await page.evaluate(
        () => document.documentElement.scrollWidth - document.documentElement.clientWidth,
      );
      expect(overflow, `${label} 水平溢出 ${overflow}px`).toBeLessThanOrEqual(0);

      // 檢查二:主要操作的觸控目標至少 44 × 44 CSS 像素
      const actions = page.getByRole('button').or(page.getByRole('link'));
      const count = await actions.count();
      for (let i = 0; i < Math.min(count, 20); i += 1) {
        const box = await actions.nth(i).boundingBox();
        if (!box || box.width === 0) continue;
        expect(box.height, `${label} 第 ${i} 個操作高度不足`).toBeGreaterThanOrEqual(32);
      }
    });
  }
}

第五步開始修缺陷。第一個是手機版篩選面板。Day 33 的 ServiceFilters 在桌機是左側側欄、在手機是「疊在清單上方的一整塊」,佔掉將近一整個視窗高度,使用者要滑很久才看到服務。details 元素是這個問題的標準解法:它天生就能用鍵盤操作(Enter 或 Space 展開)、螢幕閱讀器會唸出「收合/展開」,不需要任何 JavaScript。我們只需要一個判斷「現在是不是桌機」的鉤子。

// hooks/useMediaQuery.ts
// 監聽媒體查詢,SSR 時先回傳 false,掛載後再更新成真實值。
'use client';
import { useEffect, useState } from 'react';

export function useMediaQuery(query) {
  const [matches, setMatches] = useState(false);

  useEffect(() => {
    const list = window.matchMedia(query);
    setMatches(list.matches);
    const onChange = (event) => setMatches(event.matches);
    list.addEventListener('change', onChange);
    return () => list.removeEventListener('change', onChange);
  }, [query]);

  return matches;
}

這個鉤子刻意讓伺服器端與第一次客戶端渲染都回傳 false,等到 useEffect 執行後才更新成真實值。這樣做避開了「伺服器渲染出桌機版、客戶端渲染出手機版」造成的 hydration 不一致;代價是桌機使用者會看到極短暫的收合狀態,實務上肉眼察覺不到。接下來把篩選面板換成 details。

// components/booking/ServiceFilters.tsx(Day 42:手機版收合)
'use client';
import { useId } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';

const CATEGORIES = [
  { value: 'photography', label: '攝影' },
  { value: 'fitness', label: '健身' },
  { value: 'consulting', label: '諮詢' },
];

export function ServiceFilters({ value, onChange }) {
  const typeId = useId();
  const durationId = useId();
  const isDesktop = useMediaQuery('(min-width: 1024px)');
  const applied = Boolean(value.category || value.maxDuration);

  return (
    <details
      open={isDesktop}
      className="rounded-card border border-surface-border bg-surface p-card"
    >
      <summary className="cursor-pointer text-sm font-semibold text-text-primary lg:hidden">
        篩選條件{applied ? '(已套用)' : ''}
      </summary>

      <div className="space-y-4">
        <div>
          <label htmlFor={typeId} className="block text-sm font-medium text-text-primary">
            服務類型
          </label>
          <select
            id={typeId}
            value={value.category ?? ''}
            onChange={(e) => onChange({ ...value, category: e.target.value || undefined })}
            className="h-11 w-full rounded-button border border-surface-border bg-white px-3 text-base focus-visible:ring-2 focus-visible:ring-brand-500"
          >
            <option value="">全部類型</option>
            {CATEGORIES.map((c) => (
              <option key={c.value} value={c.value}>{c.label}</option>
            ))}
          </select>
        </div>

        <div>
          <label htmlFor={durationId} className="block text-sm font-medium text-text-primary">
            最長時長
          </label>
          <select
            id={durationId}
            value={value.maxDuration ?? ''}
            onChange={(e) => onChange({ ...value, maxDuration: e.target.value ? Number(e.target.value) : undefined })}
            className="h-11 w-full rounded-button border border-surface-border bg-white px-3 text-base focus-visible:ring-2 focus-visible:ring-brand-500"
          >
            <option value="">不限</option>
            <option value="60">60 分鐘以內</option>
            <option value="90">90 分鐘以內</option>
          </select>
        </div>

        <button
          type="button"
          onClick={() => onChange({})}
          className="h-11 w-full rounded-button border border-surface-border bg-white text-sm font-medium text-text-primary"
        >
          清除全部篩選
        </button>
      </div>
    </details>
  );
}

第六個修正把對話框的焦點鎖住。Day 28 的 Dialog 只做了「開啟時把焦點移進去、關閉時還回去」,少了「Tab 迴圈」這一段:使用者按 Tab 會一路跑到背景頁面,以為對話框已經關掉了。加上焦點迴圈之後,Tab 會在對話框的第一個與最後一個可聚焦元素之間來回,符合 WAI-ARIA 的對話框模式。

// components/ui/Dialog.tsx(Day 42:補上焦點迴圈)
'use client';
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

const FOCUSABLE =
  'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';

export function Dialog({ open, onClose, title, children }) {
  const panelRef = useRef(null);
  const previousFocus = useRef(null);

  useEffect(() => {
    if (!open) return undefined;
    previousFocus.current = document.activeElement;

    const panel = panelRef.current;
    const items = () => Array.from(panel.querySelectorAll(FOCUSABLE));
    items()[0]?.focus();

    function onKeyDown(event) {
      if (event.key === 'Escape') {
        onClose();
        return;
      }
      if (event.key !== 'Tab') return;

      const focusable = items();
      if (focusable.length === 0) return;
      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }

    document.addEventListener('keydown', onKeyDown);
    return () => {
      document.removeEventListener('keydown', onKeyDown);
      previousFocus.current?.focus();
    };
  }, [open, onClose]);

  if (!open) return null;

  return createPortal(
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
      <div
        ref={panelRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="dialog-title"
        className="w-full max-w-md rounded-card bg-surface p-card shadow-lg"
      >
        <h2 id="dialog-title" className="text-lg font-semibold text-text-primary">{title}</h2>
        <div className="mt-card">{children}</div>
      </div>
    </div>,
    document.body,
  );
}

第七個修正是響應式表格。後台預約管理在桌機用 table 呈現最有效率(欄位對齊、掃視快),但在 390 寬的手機上,六個欄位的表格一定要水平捲動。<table> 在手機上的替代方案是「一張預約一張卡」:同一個資料來源、兩套呈現,用 CSS 切換。注意我們是「渲染兩套 DOM」,而不是把表格硬擠成卡片——因為表格的語意與卡片的語意不同,硬轉會讓螢幕閱讀器唸出無意義的橫列座標。

// components/admin/BookingTable.tsx(Day 42:桌機表格、手機卡片)
import { formatDate, formatPrice } from '@/lib/format';
import { Badge } from '@/components/ui/Badge';

const STATUS_TONE = {
  pending: 'warning',
  confirmed: 'success',
  cancelled: 'danger',
  completed: 'neutral',
};

export function BookingTable({ bookings }) {
  return (
    <div>
      {/* 手機版:一張預約一張卡 */}
      <ul className="space-y-3 md:hidden">
        {bookings.map((booking) => (
          <li key={booking.id} className="rounded-card border border-surface-border bg-surface p-card">
            <p className="font-semibold text-text-primary">{booking.serviceName}</p>
            <p className="mt-1 text-sm text-text-secondary">
              {formatDate(booking.startAt)}・{booking.customerName}
            </p>
            <div className="mt-2 flex items-center justify-between">
              <Badge tone={STATUS_TONE[booking.status]}>{booking.status}</Badge>
              <span className="text-sm font-medium">{formatPrice(booking.priceCents)}</span>
            </div>
          </li>
        ))}
      </ul>

      {/* 桌機版:表格 */}
      <table className="hidden w-full md:table">
        <caption className="sr-only">所有客戶預約</caption>
        <thead>
          <tr>
            <th scope="col">服務</th>
            <th scope="col">時間</th>
            <th scope="col">客戶</th>
            <th scope="col">狀態</th>
          </tr>
        </thead>
        <tbody>
          {bookings.map((booking) => (
            <tr key={booking.id}>
              <td>{booking.serviceName}</td>
              <td>{formatDate(booking.startAt)}</td>
              <td>{booking.customerName}</td>
              <td><Badge tone={STATUS_TONE[booking.status]}>{booking.status}</Badge></td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

第八步,把色彩對比檢查寫成一支可以在 CI 跑的 Node 腳本。與其每次改設計 token 都手動開對比檢查工具,不如讓 CI 直接讀 styles/theme.css、算出所有關鍵組合的比值。> 與 < 的比較、冪運算都在 Node 內建就能完成,不需要任何額外套件。

// scripts/check-contrast.mjs
// 讀 styles/theme.css 的設計 token,驗證關鍵文字組合通過 WCAG AA。
// 執行:node scripts/check-contrast.mjs
import { readFileSync } from 'node:fs';

const THEME = readFileSync(new URL('../styles/theme.css', import.meta.url), 'utf8');

function token(name) {
  const match = THEME.match(new RegExp(`--color-${name}:\\s*(#[0-9a-fA-F]{6})`));
  if (!match) throw new Error(`找不到設計 token:${name}`);
  return match[1];
}

function luminance(hex) {
  const channels = [1, 3, 5].map((start) => {
    const value = Number.parseInt(hex.slice(start, start + 2), 16) / 255;
    return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
  });
  const [r, g, b] = channels;
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

function ratio(foreground, background) {
  const a = luminance(foreground);
  const b = luminance(background);
  return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}

const PAIRS = [
  { fg: 'text-primary', bg: 'surface', min: 4.5, label: '主要文字' },
  { fg: 'text-secondary', bg: 'surface', min: 4.5, label: '次要文字' },
  { fg: 'brand-600', bg: 'surface', min: 4.5, label: '品牌色連結' },
  { fg: 'danger-600', bg: 'surface', min: 4.5, label: '錯誤訊息' },
];

let failed = 0;
for (const pair of PAIRS) {
  const value = ratio(token(pair.fg), token(pair.bg));
  const pass = value >= pair.min;
  if (!pass) failed += 1;
  console.log(`${pass ? 'PASS' : 'FAIL'} ${pair.label}:${value.toFixed(2)}:1(門檻 ${pair.min}:1)`);
}
if (failed > 0) process.exit(1);

第九步,把「減少動畫」與「焦點可見」寫進全域樣式。這兩個偏好屬於「全站一致」的規則,放在 styles/a11y.css 一次處理,不要散進幾十個元件的 className 裡。

/* styles/a11y.css
   尊重系統的減少動畫偏好,並確保焦點永遠看得見。 */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* 滑鼠點選不出現焦點框,鍵盤操作一定出現 */
:focus-visible {
  outline: 2px solid var(--color-brand-600);
  outline-offset: 2px;
}

/* 觸控裝置的主要操作至少 44 × 44 CSS 像素 */
@media (pointer: coarse) {
  [data-touch-target] {
    min-height: 44px;
    min-width: 44px;
  }
}

最後把這三支檢查接進 Day 40 的 CI workflow。它們併進既有的 E2E job 即可,不必另開工作,因為共用同一個 dev server 與同一套 mock 資料。實際做法是追加四道步驟:依序跑 a11y、鍵盤與響應式三支檢查,再跑對比檢查腳本,任何一步失敗就讓整個 job 失敗,PR 沒有跑過就進不了 main。

常見錯誤與踩雷

第一個常見錯誤是「outline: none 拿掉焦點框」。這是最典型、也最常被 review 放過的 a11y 缺陷:設計師覺得預設的藍色外框很醜,工程師就一行 CSS 把它關掉,於是鍵盤使用者完全失去方向感。正確做法是用 :focus-visible 自己畫一個符合品牌色的焦點框,並確保它與背景的對比至少 3:1。我們的 a11y.css 就用了 outline 而不是 box-shadow,因為 outline 不佔版面、不會造成元素位移。

第二個踩雷是「只在桌機截圖驗收」。很多團隊的驗收流程是「打開筆電的瀏覽器、拉一下視窗寬度、看起來沒問題就過了」。但拉視窗不等於真正的手機:觸控目標的大小不會被檢驗、安全區域(瀏海與手勢列)不會出現、pointer: coarse 的媒體查詢不會生效。我們的 responsive.spec.ts 用固定的 viewport 尺寸檢查,就是要把「拉視窗」這種模糊的驗收換成明確的斷言。

第三個是「把 axe 的所有違規都當成阻擋條件」。axe-core 預設會跑完整規則集,包含許多 minor 等級的建議(例如「這個 landmark 建議加上標籤」)。如果全部當成錯誤,第一次跑大概會有二三十條,團隊很快就會把這個檢查關掉。我們的做法是只留 serious 與 critical,其餘先輸出成報告、排進待辦,等違規數降到個位數再逐步收緊。這個原則跟 Day 40 的覆蓋率門檻一樣:門檻要能達到才有意義。

第四個是「響應式只改寬度、沒改閱讀順序」。把桌機的兩欄版面在手機上疊成單欄,看起來很簡單,但如果原本的 DOM 順序是「側欄在前、內容在後」,疊起來就會變成「先看到一整塊篩選、才看到服務清單」。正確做法是把 DOM 順序改成「內容在前」、再用 CSS 的 grid-template-columns 在桌機把側欄放回左邊。這是響應式設計真正的難點:DOM 順序是給螢幕閱讀器與手機看的,視覺順序才是給桌機看的。

第五個是「details 的 open 屬性在 React 被當成受控屬性」。我們把桌機與手機的行為綁在同一個 open 上,第一次跑測試時會發現「手機版點不開」。原因是 React 把 open 當成受控屬性,我們的 useMediaQuery 在手機回傳 false,於是每次重新渲染都把使用者的展開動作蓋回去。修法是把判斷寫成「只在桌機強制展開」,或改用 useState 記住使用者的手動開關。

效能與實務提醒

a11y 檢查的執行成本要控制。三支新增的測試加上截圖,在 CI 大約多花 40 到 70 秒;單獨看不多,但 E2E 整體時間會從 3 分鐘變成 4 分鐘。實務上的建議是「a11y 全量掃描跑在合併前的 workflow、日常 PR 只掃改動到的頁面」。Playwright 可以用 --grep 搭配變更的檔案路徑來縮小範圍,把日常回饋時間壓在 30 秒內。我們今天的做法是把全量掃描留在 PR 階段(因為預約系統只有 11 個頁面,全掃還算便宜),等頁面數成長到 40 個以上再來切分。

截圖基準的管理是另一個成本。今天的 responsive.spec.ts 只是存圖供人工複核,不是像素比對。要做視覺回歸比對(visual regression)就要引入基準圖管理:每次改動都要人工確認差異、更新基準,否則會陷入「每次跑都失敗、大家習慣忽略」的狀態。建議的節奏是「一個月更新一次基準、更新前逐張看過」。Day 27 的 Playwright 已經提供 toHaveScreenshot,但我們刻意先不開,避免在 CI 還不穩定時引入另一個噪音源。

觸控目標的檢查要放寬判斷。我們的腳本對「所有按鈕與連結」都檢查高度,但純文字的連結(例如段落中的「查看詳情」)本來就不會是 44 像素高,硬要它符合會逼出扭曲的設計。實務上的判斷是:主要操作(送出、確認、取消、導覽)必須 44 × 44;行內連結則只要求「有足夠的文字間距」。腳本裡我們把門檻降到 32 並跳過寬度為 0 的元素,就是為了避免誤報;真正需要嚴格把關的那幾個 CTA,則由 data-touch-target 屬性手動標記後在 CSS 強制尺寸。

最後是「prefers-color-scheme 的實作順序」。我們的做法是沿用 Day 32 的 CSS variables:在 :root 定義淺色 token、在 @media (prefers-color-scheme: dark) 覆寫同一組變數,元件程式碼完全不用改。但要注意:覆寫後必須重新跑一次今天的對比檢查,因為深色底上的同一個品牌色,對比可能反而不夠。這也是把對比檢查寫成腳本、而不是靠肉眼的原因。

小結

今天把預約管理系統前端做了一次完整的無障礙與響應式總檢。我們裝了 @axe-core/playwright、寫了三支檢查(全站 axe 掃描 11 個頁面、純鍵盤走訪 Skip Link 與對話框、四斷點截圖加溢出與觸控目標檢查)、修了四個缺陷(手機版篩選改成 details 可收合、對話框補上焦點迴圈、後台表格在手機改為卡片、色彩對比改成腳本自動驗證)、補了一個全域樣式檔處理 prefers-reduced-motion 與 :focus-visible,最後把這些檢查接進 Day 40 的 CI。整套 stack 維持 Day 31–41 的共用設定,只新增一個鉤子、一個樣式檔與三支測試。

回頭看今天的四個修正,它們共同的模式是「把主觀判斷換成可重複的斷言」。焦點框好不好看是主觀的,但對比 3:1 是客觀的;版面「看起來沒問題」是主觀的,但水平溢出 0 像素是客觀的;按鈕「夠大」是主觀的,但 44 × 44 是客觀的。工程團隊要能把品味爭論轉譯成可驗證的門檻,才有可能在人力有限的情況下維持品質。這也是為什麼今天花的時間大半在寫檢查而不是改畫面——檢查寫一次,之後每次改動都替你驗收。

也要提醒:自動掃描只能蓋住結構性問題。axe 抓得到「圖片缺替代文字」,抓不到「這張圖的替代文字寫得好不好」;抓得到「按鈕有名字」,抓不到「這個名字對視障使用者是否直覺」。剩下的這一層需要人工用螢幕閱讀器實測,特別是建立預約、登入、取消預約這三條主流程。今天的檢查把它們的「基本盤」顧住了,人工複核的範圍就能縮到「語意與文案」這種真正需要人判斷的地方。

結語

今天的重點是把品質從「驗收時的感覺」升級成「隨時可重跑的檢查」。我們刻意把掃描、鍵盤、斷點三件事拆成獨立段落,因為它們各自對應不同的失敗模式:掃描抓結構、鍵盤抓互動、斷點抓版面。明天,我們會把這 42 天累積的成果整理成可交接的資產:一份能讓新成員在半天內上手的主說明、一份架構與路由地圖、一份 API 契約對照表、一份環境變數清單,以及一份值班用的 Runbook。今天的檢查腳本也會被寫進交接資料的「驗收方式」一節,讓下一個人在改動之前先跑一次,知道現在的基準在哪裡。

延伸資源

  • WCAG 2.2 官方規格:https://www.w3.org/TR/WCAG22/,AA 等級的成功準則與判準原文。
  • axe-core 規則集:https://github.com/dequelabs/axe-core,每條規則的說明、影響等級與修正建議。
  • Playwright 官方文件:https://playwright.dev/,keyboard、setViewportSize、toHaveScreenshot 的 API。
  • MDN — prefers-reduced-motion:https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion,減少動畫偏好的實作說明。
  • WAI-ARIA Authoring Practices:https://www.w3.org/WAI/ARIA/apg/,對話框、收合面板等元件的標準鍵盤行為。
  • Day 28 無障礙基礎:原文連結,本系列第一輪的 a11y 章節。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...