Chuyển đến nội dung
MINH VO Ghi chép từ công việc
của một kỹ sư tại Việt Nam
Cơ bản12 phút đọc

Kiểu focus vẫn rõ khi đổi giao diện sáng tối

Giữ nguyên điều khiển có focus khi đổi theme, giữ dấu hiệu cho chuột và kiểm tra cắt viền, khoảng chừa viewport cùng màu cưỡng chế.

Mô hình giấy màu ô liu và be thể hiện trang trình duyệt trên máy tính và trang hẹp trên điện thoại

Đổi giao diện sáng/tối nên thay màu sắc quanh công việc người đọc đang làm. Nếu nút đang nhận focus biến mất rồi một nút trông giống hệt thay vào, màu có thể đúng nhưng việc điều hướng bàn phím đã mất vị trí. Vì vậy, cách thể hiện focus ổn định cần cả dấu hiệu nhìn thấy được lẫn sự liên tục của phần tử nhận đầu vào.

Bài này dành cho lập trình viên frontend đã quen selector, thuộc tính DOM và điều khiển HTML gốc. Ta dựng nút đổi theme cục bộ, so sánh focus từ chuột và bàn phím, đo đường viền bị cắt, rồi kiểm tra bảng màu cưỡng chế. Đây là bài tập component. Nó không xác nhận mọi bề mặt trong ứng dụng đạt WCAG hay mọi trình duyệt dùng cùng quy tắc suy đoán focus.

Giữ dấu hiệu cơ bản rồi tăng độ nổi bật khi cần

Ba selector trả lời ba câu hỏi khác nhau. :focus xác định phần tử đang nhận focus. :focus-visible khớp khi trình duyệt quyết định nên thể hiện focus bằng dấu hiệu nhìn thấy được. :focus-within còn cho tổ tiên phản hồi khi một phần tử con có focus. Đặc tả Selectors chủ động để trình duyệt chọn cách suy đoán; các quy tắc gợi ý của nó không phải yêu cầu bắt buộc.

Những gợi ý ấy gồm thao tác bàn phím, ô văn bản cần nhập bằng bàn phím, tùy chọn người dùng và việc script chuyển focus. Coi focus-visible là tên khác của “sự kiện cuối là Tab” sẽ bỏ sót các trường hợp đó. Ô văn bản vừa được nhấp chuột vẫn có thể khớp. Việc chuyển focus bằng script có thể phụ thuộc trạng thái focus trước đó.

Thiết kế của ta giữ outline hai pixel cho mọi điều khiển có focus, rồi tăng lên ba pixel khi focus-visible khớp. Không cần giấu focus do chuột tạo ra để dùng selector này. Giải thích Focus Visible của WCAG nêu rõ người dùng thiết bị trỏ cũng có thể hưởng lợi từ dấu hiệu ấy.

Đặt markup sau trong tài liệu riêng có thẻ meta viewport. Tên truy cập của nút luôn là “Giao diện tối”; trạng thái pressed cho biết tùy chọn đang bật hay tắt. Ô văn bản gốc có label, còn liên kết đi tới heading thật. Nút theme ban đầu bị vô hiệu hóa cho tới khi script cài hành vi, nên trang không chạy script vẫn có ô tên và liên kết dùng được mà không để lại một nút đang bật nhưng không làm gì.

<main class="focus-demo" data-theme="light">
  <h1>Giữ vị trí focus khi đổi giao diện</h1>
  <h2 id="controls-title">Tùy chọn đọc</h2>
  <div class="focus-controls" role="group" aria-labelledby="controls-title">
    <button id="theme-button" type="button" aria-pressed="false" disabled>
      Giao diện tối
    </button>
    <label for="display-name">Tên hiển thị
      <input id="display-name" name="displayName" type="text"
             autocomplete="name" value="Người đọc">
    </label>
    <a id="notes-link" href="#reading-notes">Đọc ghi chú về focus</a>
  </div>
  <h2 id="reading-notes" tabindex="-1">Ghi chú về focus</h2>
  <p>Giao diện đổi màu sắc. Chính nút, ô tên và liên kết ấy vẫn ở trong tài liệu.</p>
</main>

Border của nhóm là dấu hiệu vị trí bổ sung, còn outline của điều khiển xác định chính xác nơi nhận đầu vào. Chỉ tô sáng nhóm sẽ không cho người đọc biết điều khiển nào trong ba điều khiển đang hoạt động.

.focus-demo {
  --surface: #f4f0e6;
  --ink: #303326;
  --ring: #1c51a0;
  --group: #77824d;
  box-sizing: border-box;
  max-inline-size: 42rem;
  margin-inline: auto;
  padding: 1.5rem;
  color: var(--ink);
  background: var(--surface);
  font: 1rem/1.5 system-ui, sans-serif;
  overflow-wrap: anywhere;
}
.focus-demo[data-theme="dark"] {
  --surface: #303326;
  --ink: #f4f0e6;
  --ring: #f4d77c;
  --group: #b9c99a;
}
.focus-demo *, .focus-demo *::before, .focus-demo *::after {
  box-sizing: inherit;
}
.focus-controls {
  display: grid;
  gap: 1.25rem;
  padding: 1rem;
  border: 2px solid transparent;
}
.focus-controls:focus-within { border-color: var(--group); }
.focus-demo label { display: grid; gap: .5rem; }
.focus-demo :where(button, input) {
  min-inline-size: 0;
  max-inline-size: 100%;
  padding: .5rem .75rem;
  border: 1px solid var(--ink);
  color: inherit;
  background: var(--surface);
  font: inherit;
}
.focus-demo button { justify-self: start; }
.focus-demo input { inline-size: 100%; }
.focus-demo a { color: inherit; text-underline-offset: .18em; }
.focus-demo :where(a, button, input, [tabindex]) { scroll-margin-block: 1rem; }
.focus-demo :where(a, button, input, [tabindex]):focus {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}
.focus-demo :where(a, button, input, [tabindex]):focus-visible {
  outline-width: 3px;
  outline-offset: 3px;
}
@media (forced-colors: active) {
  .focus-demo, .focus-demo[data-theme="dark"] {
    --surface: Canvas;
    --ink: CanvasText;
    --ring: Highlight;
    --group: Highlight;
  }
}

Hai quy tắc focus được viết riêng. Nếu selector tăng cường không được hỗ trợ, trình duyệt có thể bỏ quy tắc đó trong khi quy tắc focus thông thường vẫn còn. Đây là phương án dự phòng cho tính năng ấy, không phải lời hứa mọi khai báo khác đều chạy trên trình duyệt cũ bất kỳ. Nếu toàn bộ stylesheet không tải được, điều khiển gốc vẫn có cách trình bày của trình duyệt.

Đổi trạng thái mà không thay điều khiển

Đặt script sau markup. Nó cài một listener click gốc, vốn cũng nhận kích hoạt từ bàn phím, rồi bật nút. Space và Enter đã thuộc hành vi của button; thêm một lần đảo trạng thái trong keydown có thể khiến cùng thao tác chạy hai lần.

const demo = document.querySelector('.focus-demo');
const themeButton = document.querySelector('#theme-button');

themeButton.addEventListener('click', () => {
  const dark = demo.dataset.theme !== 'dark';
  demo.dataset.theme = dark ? 'dark' : 'light';
  themeButton.setAttribute('aria-pressed', String(dark));
});
themeButton.disabled = false;

Khi kích hoạt, chỉ thuộc tính theme và pressed đổi. Nút, input và liên kết giữ nguyên đối tượng. Giá trị đã sửa trong input vẫn nằm trên chính đối tượng ấy, còn nút đang có focus tiếp tục nhận thao tác bàn phím sau đó. Không cần gọi focus để sửa một phần tử chưa từng bị gỡ đi.

Trong trình duyệt đã kiểm tra, Tab tới nút với outline xanh ba pixel. Space bật bảng màu tối và đổi outline sang vàng nhạt trong khi focus vẫn ở cùng nút. Enter quay lại bảng màu sáng. Vị trí và kích thước border box đã đo của các điều khiển không đổi qua chuỗi này. Nhóm vốn đã có border trong suốt hai pixel, nên tô màu border không thêm chiều rộng bố cục.

Cùng nút đổi theme đang nhận focus có outline xanh ở bảng màu sáng và vàng ở bảng màu tối; đối tượng DOM cùng vị trí bàn phím không đổi khi trạng thái pressed thay đổiMở ảnh kích thước đầy đủ ↗

Độ tương phản vẫn phụ thuộc màu kề bên thực tế. Các token này dành cho hai bề mặt phẳng đang minh họa. Đưa điều khiển lên ảnh, lớp phủ trong suốt hoặc thẻ có màu khác đòi hỏi kiểm tra vị trí mới. Chỉ tên theme không chứng minh outline phân biệt được với vùng xung quanh.

Nếu framework dựng lại cây con mỗi lần đổi theme, id giống nhau trên phần tử thay thế không giữ focus của trình duyệt. Đối tượng cũ đã bị ngắt khỏi tài liệu. Thay đổi cấu trúc thật đôi lúc cần đặt focus có chủ đích, nhưng thay đổi cách trình bày thường có thể giữ các phần tử hiện hữu. Ví dụ này không lưu tùy chọn theme qua lần tải trang; thêm lưu trữ là quyết định trạng thái riêng.

Quan sát focus mà không làm nó thay đổi

Chạy đoạn chẩn đoán sau trong console khi trang có focus. Nó đọc ba điều khiển mà không chuyển focus hay đổi thuộc tính. Nếu thao tác với công cụ phát triển làm trạng thái focus của trang thay đổi, hãy quay lại trang và lặp chuỗi bàn phím trước khi diễn giải kết quả.

(() => {
  const demo = document.querySelector('.focus-demo');
  const supportsVisible = CSS.supports('selector(:focus-visible)');
  const rows = [...demo.querySelectorAll('button, input, a')].map(control => {
    const style = getComputedStyle(control);
    const box = control.getBoundingClientRect();
    return {
      id: control.id,
      active: document.activeElement === control,
      visibleMatch: supportsVisible ? control.matches(':focus-visible') : null,
      theme: demo.dataset.theme,
      outline: style.outlineStyle,
      width: style.outlineWidth,
      offset: style.outlineOffset,
      color: style.outlineColor,
      boxWidth: box.width,
      boxHeight: box.height,
    };
  });
  console.table(rows);
  return rows;
})();

Trong một chuỗi thao tác chuột mới trên bản Chrome đã thử, nhấp nút để focus-visible không khớp và dùng outline cơ bản hai pixel. Nhấp ô văn bản làm focus-visible khớp và dùng outline ba pixel. Tab tiếp tới liên kết có dấu hiệu mạnh hơn. Những quan sát này minh họa lựa chọn của trình duyệt; chúng không phải bảng ánh xạ sự kiện sang selector áp dụng cho mọi trình duyệt.

Đoạn chẩn đoán báo computed style và border box, không chụp các pixel thật sự nhìn thấy. Outline ba pixel đúng trong computed style vẫn có thể bị cắt, bị che hoặc không đủ khác nền. Muốn trả lời các câu hỏi ấy cần kiểm tra không gian riêng.

Tái hiện outline đúng nhưng bị tổ tiên cắt mất

Thêm phép thử riêng này sau ví dụ chính. Các nút không có hành động ứng dụng: chúng là đích focus để so sánh hai hộp cắt nội dung. Dùng Tab để tới từng nút, hoặc đặt focus bằng công cụ phát triển trong khi xem pixel.

<section class="clip-lab" aria-labelledby="clip-title">
  <h2 id="clip-title">Thử khoảng chừa cho đường viền</h2>
  <div class="clip-probe" data-room="no">
    <button class="probe-control" type="button">Đường viền bị cắt</button>
  </div>
  <div class="clip-probe" data-room="yes">
    <button class="probe-control" type="button">Chừa tám pixel</button>
  </div>
</section>
.clip-lab {
  max-inline-size: 42rem;
  margin: 2rem auto;
  padding: 1.5rem;
  font: 1rem/1.5 system-ui, sans-serif;
  overflow-wrap: anywhere;
}
.clip-lab, .clip-lab * { box-sizing: border-box; }
.clip-probe {
  inline-size: min(100%, 18rem);
  margin-block: 1.5rem;
  padding: 0;
  overflow: hidden;
  background: #f4f0e6;
}
.clip-probe[data-room="yes"] { padding: 8px; }
.probe-control {
  display: block;
  inline-size: 100%;
  margin: 0;
  padding: .5rem .75rem;
  border: 1px solid #303326;
  color: #303326;
  background: #f4f0e6;
  font: inherit;
  overflow-wrap: anywhere;
}
.probe-control:focus {
  outline: 3px solid #1c51a0;
  outline-offset: 3px;
}

Với phép thử chữ nhật đơn giản này, offset ba pixel tạo khoảng trống ngoài nút, rồi nét ba pixel mở rộng vùng vẽ ra sáu pixel khỏi border. Trong hộp đầu, nút chạm mọi cạnh cắt. Border vẫn hiện nhưng outline bên ngoài bị cắt mất. Các giá trị computed của outline vẫn đúng.

Hộp thứ hai chừa tám pixel mỗi cạnh. Outline cùng khoảng trống chiếm sáu, để lại hai trước cạnh cắt. Thêm padding đổi chiều rộng nội dung dành cho nút vì chiều rộng ngoài của hộp thử giữ nguyên; đó là quyết định bố cục thật, không phải trang trí miễn phí. Outline cơ bản hai pixel trong ví dụ chính cần bốn pixel bên ngoài, còn trạng thái mạnh hơn cần sáu.

Nút chạm cạnh cắt mất outline focus bên ngoài; padding tám pixel của tổ tiên chứa đủ outline cùng khoảng trống rộng sáu pixel và còn lại hai pixelMở ảnh kích thước đầy đủ ↗

Quy tắc outline trong CSS nói outline không chiếm chỗ bố cục và có thể không phải hình chữ nhật. Phép tính ở đây dành cho điều khiển chữ nhật không transform, với outline nét liền đã nêu. Nó không suy ra mọi outline của inline nhiều dòng, outline auto riêng nền tảng hay kết quả sau transform.

Vẽ outline vào trong là cách sửa khác, nhưng nó dùng vùng nội thất của điều khiển và có thể cạnh tranh với chữ hay border. Bỏ cắt ở tổ tiên lại thay đổi nội dung nào được phép tràn ra. Hãy chọn khoảng chừa, dấu hiệu bên trong hoặc ranh giới cắt khác theo nhiệm vụ thật của component.

Tôn trọng bảng màu cưỡng chế và giữ điều khiển nhìn thấy được

Forced-colors có thể thay màu tác giả bằng màu hệ thống. Quy tắc CSS Color Adjustment còn làm box-shadow và text-shadow có giá trị computed là none. Vì thế dấu hiệu focus chỉ dùng bóng có kiểu thất bại khác với outline.

Ví dụ chính dùng Canvas, CanvasText và Highlight trong chế độ đó, đồng thời giữ forced-color-adjust là auto. Outline vẫn là nét liền thật. Đổi trạng thái pressed của theme tối vẫn có thể đổi thuộc tính tùy chọn dù bảng màu cưỡng chế khiến hai bề mặt theme trông giống nhau. Pressed mô tả tùy chọn; nó không nên hứa hệ thống sẽ tôn trọng mọi màu tác giả chọn.

Mô phỏng media trong trình duyệt kiểm tra cách CSS phản hồi với forced colors. Đó là bằng chứng hữu ích nhưng không thay việc thử theme tương phản thật của hệ điều hành cùng các tổ hợp trình duyệt mà sản phẩm hỗ trợ. Đừng tắt điều chỉnh màu trên cả component chỉ để giữ ảnh chụp thông thường.

Scroll margin một rem trên các đích focus chính còn chừa chỗ khi thao tác cuộn đưa chúng vào vùng nhìn. Trong lần thử bàn phím với chữ lớn, bỏ margin đó khiến trình duyệt đặt đáy liên kết cách mép viewport chưa tới nửa pixel, cắt vòng bên ngoài. Quy tắc cuối giữ được khoảng chừa trong chuỗi đã kiểm tra. Scroll margin cũng tác động tới đích fragment khi không bật snapping; nó đổi vùng được yêu cầu đưa vào nhìn thấy, không đổi margin bố cục của phần tử. Hành vi cuộn khi focus khác vẫn cần thử trên các trình duyệt được hỗ trợ.

Cuối cùng, outline không cứu được điều khiển bị che sau panel sticky. Focus Not Obscured (Minimum) đánh giá điều khiển có bị nội dung do tác giả tạo che hoàn toàn hay không; đó là tiêu chí khác Focus Visible. Trong bài tập này, ta kiểm tra toàn bộ outline như mục tiêu thiết kế cục bộ cao hơn, không khẳng định tiêu chí tối thiểu yêu cầu mọi pixel outline bên ngoài đều không bị che.

Lặp toàn bộ công việc ở chiều rộng 320 CSS pixel với chữ mặc định lớn hơn và khoảng cách chữ bổ sung: tới nút theme, kích hoạt nó, sửa tên, đi theo liên kết ghi chú rồi so hai hộp thử cắt. Kiểm tra focus được giữ, tên điều khiển đầy đủ khi xuống dòng, pixel của dấu hiệu và đích bàn phím tiếp theo. Mỗi quan sát kiểm tra một phần khác của việc giữ vị trí cho người đọc.

Nguồn tham khảo & đọc thêm

  1. MDN: focus-visible
  2. Selectors Level4: focus states
  3. CSS User Interface Level4: outlines
  4. CSS Color Adjustment Level1: forced colors
  5. WCAG22 Understanding: Focus Visible
  6. WCAG22 Understanding: Focus Not Obscured Minimum
  7. CSS Scroll Snap Level1: scroll margin
← Về trang bài viết

Hình minh họa

100%