Học từ Facebook #8: Focus trap và Esc theo layer cho dialog
Cách Facebook giữ focus trong dialog, trả focus khi đóng và chỉ đóng layer trên cùng khi bấm Esc. Viết lại bằng React, có demo dialog lồng nhau.
Dialog tự viết thường làm đúng phần nhìn thấy (nền mờ, nút đóng) nhưng sai phần bàn phím:
- Bấm Tab, focus chạy ra các nút phía sau lớp nền mờ. Người dùng bàn phím "lạc" vào một trang họ không nhìn thấy.
- Đóng dialog, focus rơi về
<body>. Người dùng bàn phím và trình đọc màn hình phải Tab lại từ đầu trang. - Dialog lồng nhau (dialog xác nhận mở trên dialog sửa), bấm Esc thì cả hai cùng đóng.
- Đang gõ tiếng Nhật, tiếng Trung (hoặc tiếng Việt bằng bộ gõ có gạch chân chữ đang soạn, như bộ gõ mặc định của macOS) trong ô input của dialog, bấm Esc để huỷ chữ đang soạn thì dialog đóng mất, kèm theo nội dung vừa gõ.
Tip
Tóm tắt
- Nếu được, dùng
<dialog>+showModal(): trình duyệt tự làm nền thànhinert, đóng bằng Esc và trả focus. Bài này dành cho popover, menu, dialog tự viết, hoặc khi cần Esc theo layer. - Focus trap gồm 3 phần: lưu nơi focus cũ (ngay lúc render), giữ Tab vòng trong dialog, trả focus khi đóng.
- Esc chỉ đóng layer trên cùng, và phải nhường khi đang gõ IME hoặc khi component con đã tự xử lý Esc.
- Focus trap chỉ chặn Tab; muốn trình đọc màn hình không "lọt" ra ngoài thì cần
aria-modalvà tốt nhất làinertcho phần nền.
#Bên trong trình duyệt: focus được quản lý thế nào?
Sequential focus navigation. Khi bấm Tab, trình duyệt đi theo một danh sách gọi là sequential focus navigation order:
tabindex |
Ý nghĩa |
|---|---|
| không có | theo mặc định của thẻ: button, input, a[href]… nằm trong danh sách; div, span thì không |
0 |
đưa vào danh sách theo thứ tự DOM |
-1 |
focus được bằng code (el.focus()) nhưng Tab bỏ qua |
số dương (1, 2…) |
được Tab tới trước mọi phần tử khác, theo thứ tự số. Gần như luôn là ý tưởng tồi |
Ngoài ra còn những trường hợp ngầm: element disabled, display: none, visibility: hidden hay nằm trong vùng inert đều không focus được. Nhóm radio chỉ có một radio trong danh sách (cái đang được chọn), mũi tên mới chuyển giữa các radio.
focus không bubble, focusin thì có. Muốn nghe "focus đi đâu" ở cấp document thì phải dùng focusin/focusout, hoặc dùng focus với capture: true.
Trình đọc màn hình không chỉ dùng Tab. VoiceOver, NVDA, JAWS có virtual cursor: người dùng di chuyển qua từng đoạn văn, heading, link bằng phím mũi tên hoặc vuốt, mà không cần focus. Focus trap chỉ chặn Tab. Nó không ngăn trình đọc màn hình đọc nội dung phía sau dialog. Phần đó cần:
role="dialog"+aria-modal="true": báo cho trình đọc màn hình rằng ngoài dialog là "không tồn tại". Trình đọc màn hình hiện đại hỗ trợ khá tốt nhưng không hoàn hảo.inerttrên phần nội dung phía sau: trình duyệt loại hẳn vùng đó khỏi focus, click và cây accessibility. Đây là cách chắc chắn nhất.<dialog>mở bằngshowModal(): trình duyệt tự làm tất cả những việc trên.
#Facebook làm thế nào?
#1. getTabbableNodes
Hàm nhận một "query" và một scope, trả về [nodes, first, last, currentIndex, current]. Bộ lọc của nó:
offsetWidth > 0 && offsetHeight > 0(loại element không hiển thị),tabIndex !== -1,getComputedStyle(el).visibility !== "hidden",- luôn giữ lại element đang focus, kể cả khi nó không thoả các điều kiện trên. Nếu không,
currentIndexsẽ là-1và không tính được "Tab tiếp theo đi đâu".
Điểm đáng chú ý: Facebook không dùng CSS selector mà dùng unstable_Scope, một API thử nghiệm của React cho phép truy vấn các node con của một component (DO_NOT_USE_queryAllNodes(fn)) bằng hàm. Các query (focusScopeQueries) quyết định node nào "có thể tab tới", "là header", v.v.
#2. FocusRegion
Component bao một vùng với các tuỳ chọn:
containFocusQuery: giữ focus trong vùng. Ở mỗi lần bấm Tab, FocusRegion tự tính danh sách node rồi tự focus node kế tiếp vàpreventDefault(). Cách này tốn hơn nhưng không phụ thuộc vào thứ tự Tab của trình duyệt (tabindex dương, radio group, shadow DOM…). Nó cũng bỏ qua nếuevent.isDefaultPrevented(), để component con có thể tự xử lý Tab.autoFocusQuery: khi mount, nếu focus chưa nằm trong vùng thì focus vào element được focus lần cuối trong vùng (nếu còn hiển thị), không thì vào node đầu tiên thoả query. Mở lại một panel, bạn quay về đúng chỗ đang đứng.autoRestoreFocus: lưudocument.activeElementngay lúc render đầu tiên (tronguseRef), không phải trong effect. Khi unmount, trongrequestAnimationFrame, nó trả focus về element đó, nhưng chỉ khi focus hiện đang nằm trong vùng sắp bị huỷ hoặc đã rơi vềbody. Nếu người dùng đã tự click sang chỗ khác thì không giành lại focus.recoverFocusQuery(chiến lược mặc địnhNearest): khi element đang được focus bị xoá khỏi DOM (ví dụ xoá một comment trong danh sách), FocusRegion tìm node gần nhất còn lại (node kế tiếp sau vị trí cũ, hoặc node trước đó) và focus vào đó. Không có cơ chế này, xoá một item bằng bàn phím sẽ làm focus rơi vềbody.- Vùng lồng nhau được nối với nhau qua
ActiveFocusRegionUtilsContext. Mỗi vùng nhớ "vùng nào đã mở mình" để trả focus đúng chuỗi khi đóng nhiều lớp.
#3. Phím tắt theo lớp
CometHideLayerOnEscape chỉ đăng ký một command: phím ESCAPE, handler là onHide, kèm triggerFromInputs: true (Esc vẫn hoạt động khi đang gõ trong input). Phần thú vị nằm ở hạ tầng phía dưới:
- Một listener duy nhất cho
keydownvàkeyuptrênwindow(BaseKeyCommandListener). - Bỏ qua khi đang soạn chữ bằng IME:
useGetComposingStatetrả vềevent.isComposing || đang trong khoảng compositionstart…compositionend. Cờ thứ hai cần thiết vì một số trình duyệt bắnkeydowncủa phím Esc saucompositionend, lúc đóisComposingđã làfalse. - Tìm handler theo vị trí focus. Mỗi
KeyCommandWrapperlà một node trong cây. Wrapper chứa element đang focus là "active wrapper".applyKeyCommandthử từ active wrapper, lên dần các wrapper cha (getParent()), rồi tới layer đang active, cuối cùng là global. Handler đầu tiên nhận lệnh thắng, và mặc định nópreventDefault()+ dừng lan truyền. - Command một ký tự (như
j/kđể lướt feed) không chạy khi đang gõ trong input. Lần đầu dùng, Facebook còn hiện dialog hỏi người dùng có muốn bật phím tắt một ký tự không. Đây chính là yêu cầu WCAG 2.1.4 Character Key Shortcuts: người dùng nhập liệu bằng giọng nói có thể vô tình "gõ" ra các ký tự đó. - Đăng ký trùng phím trong cùng wrapper thì log cảnh báo
Conflicting key command registered.
flowchart TD
K["keydown trên window"] --> C{"Đang soạn chữ IME?"}
C -- "có" --> X["Bỏ qua"]
C -- "không" --> W["Active wrapper chứa focus"]
W --> H1{"Có handler cho phím này?"}
H1 -- "có" --> R["Chạy handler, preventDefault"]
H1 -- "không" --> P["Wrapper cha"]
P --> H1
P -- "hết cha" --> L["Layer đang active"]
L --> G["Global commands"]
Nguồn trong facebook-codebase: FocusRegion, FocusManager, getTabbableNodes, focusScopeQueries (thư mục FocusRegion/), CometHideLayerOnEscape, createKeyCommandWrapper, CometKeyCommandContext (thư mục CommetKeyCommands/); BaseKeyCommandListener, applyKeyCommand, useGetComposingState nằm trong các file bundle lớn như 26.js.
#Viết lại
Bản viết lại gồm hai hook độc lập: useFocusTrap (Tab + trả focus) và useEscapeLayer (Esc theo lớp).
Khác bản gốc, nói trước để dễ theo dõi:
- Chỉ can thiệp Tab ở hai đầu danh sách (Facebook tự focus node kế tiếp ở mọi lần Tab). Rẻ hơn, đổi lại phụ thuộc thứ tự Tab của trình duyệt.
- Esc dùng stack theo thứ tự mount. Facebook chính xác hơn: layer nào đang chứa focus thì layer đó nhận phím, bất kể mở trước hay sau.
- Không có
recoverFocusQuery(focus lại khi element đang focus bị xoá).
#Tìm node có thể Tab tới
src/lib/focusTrap.ts
export function getTabbableNodes(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR)).filter((el) => {
if (el.tabIndex < 0) return false;
// nằm trong vùng `inert`, `fieldset` bị disable hay `<details>` đang đóng: trình duyệt
// không cho focus, nên cũng không được tính vào vòng Tab
if (el.closest('[inert]')) return false;
if (el.matches(':disabled')) return false;
const closedDetails = el.closest('details:not([open])');
if (closedDetails && !(el.tagName === 'SUMMARY' && el.parentElement === closedDetails)) {
return false;
}
// bị ẩn bằng display:none / kích thước 0 / visibility:hidden thì không focus được
if (el.offsetWidth === 0 && el.offsetHeight === 0 && el.getClientRects().length === 0) {
return false;
}
return getComputedStyle(el).visibility !== 'hidden';
});
}
ts
Vì không có unstable_Scope (API thử nghiệm, không có trong bản React ổn định), ta dùng CSS selector (TABBABLE_SELECTOR trong file đầy đủ) và lọc thêm bằng JavaScript:
- Loại element nằm trong vùng
inert, element:disabled(kể cả nằm trongfieldsetbị disable) và element trong<details>đang đóng (trừ chính thẻsummarycủa nó). - Kiểm tra
offsetWidth/offsetHeight/getClientRects()để loạidisplay: none(element códisplay: nonehoặc nằm trong chadisplay: nonecó kích thước 0 và không có client rect nào). visibility: hiddenphải kiểm tra riêng vì element ẩn kiểu này vẫn chiếm chỗ.
#useFocusTrap từng bước
src/lib/focusTrap.ts
export function useFocusTrap(
containerRef: RefObject<HTMLElement | null>,
active: boolean,
{ autoFocus = true, restoreFocus = true }: UseFocusTrapOptions = {},
) {
// Ghi lại element đang focus NGAY LÚC RENDER lần đầu active (giống autoRestoreFocus của FB).
// Nếu đợi tới useEffect thì quá muộn: input con có `autoFocus` đã được React focus trong
// giai đoạn commit, và ta sẽ "nhớ nhầm" chính input đó.
// Lưu ý: đọc DOM và đọc/ghi ref trong lúc render là phá quy tắc của React (lint / React
// Compiler có thể cảnh báo). Ở đây chấp nhận được vì thao tác idempotent: render lại bao
// nhiêu lần thì kết quả vẫn như nhau.
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
const wasActiveRef = useRef(false);
if (active && !wasActiveRef.current && typeof document !== 'undefined') {
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
}
wasActiveRef.current = active;
useEffect(() => {
const container = containerRef.current;
if (!active || !container) return;
const previouslyFocused = previouslyFocusedRef.current;
if (autoFocus && !container.contains(document.activeElement)) {
// không có gì focus được thì focus chính container (cần tabIndex=-1)
(getTabbableNodes(container)[0] ?? container).focus();
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return;
// dialog lồng nhau: chỉ dialog chứa element đang focus mới xử lý
if (!container.contains(document.activeElement)) return;
const nodes = getTabbableNodes(container);
if (nodes.length === 0) {
event.preventDefault();
return;
}
const first = nodes[0];
const last = nodes[nodes.length - 1];
const current = document.activeElement;
if (event.shiftKey && (current === first || current === container)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && current === last) {
event.preventDefault();
first.focus();
}
};
// Focus lọt ra ngoài (click ra nền, công cụ trợ năng…) thì kéo lại vào trong.
// Bỏ qua nếu focus đi vào một dialog khác mở chồng lên trên.
const onFocusIn = (event: FocusEvent) => {
const target = event.target as Node;
if (container.contains(target)) return;
if (target instanceof Element && target.closest('[data-focus-trap]')) return;
(getTabbableNodes(container)[0] ?? container).focus();
};
document.addEventListener('keydown', onKeyDown);
document.addEventListener('focusin', onFocusIn);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('focusin', onFocusIn);
// chỉ trả focus nếu element cũ vẫn còn trên trang
if (restoreFocus && previouslyFocused?.isConnected) previouslyFocused.focus();
};
}, [active, autoFocus, restoreFocus, containerRef]);
}
ts
- Lưu nơi focus cũ ngay lúc render đầu tiên
activechuyển thànhtrue(previouslyFocusedRef), trước khi bất cứ thứ gì kịp động vào focus. Lý do ở mục "Tại sao không…" bên dưới. - Auto focus: nếu focus chưa ở trong dialog, focus node đầu tiên. Không có node nào thì focus chính container. Đó là lý do container có
tabIndex={-1}: focus được bằng code nhưng không lọt vào vòng Tab. - Tab ở hai đầu: chỉ can thiệp khi đang ở node cuối mà bấm Tab, hoặc ở node đầu (hay container) mà bấm Shift+Tab. Ở giữa thì để trình duyệt tự xử lý. Cách này rẻ hơn FocusRegion (không tính lại danh sách ở mỗi lần Tab), đổi lại phụ thuộc vào thứ tự Tab của trình duyệt.
- Dialog lồng nhau: listener nằm trên
document, nên mọi dialog đang mở đều nhận được phím Tab. Dòngif (!container.contains(document.activeElement)) returnđảm bảo chỉ dialog đang chứa focus xử lý. - Lưới an toàn
focusin: nếu focus vẫn lọt ra ngoài (click vào nền, radio group làm sai phép tính "node cuối", công cụ trợ năng tự di chuyển focus), kéo nó trở lại. Bỏ qua khi focus đi vào một vùng códata-focus-trapkhác, tức một dialog mở chồng lên. - Trả focus khi đóng, chỉ khi element cũ vẫn còn trong DOM (
isConnected). Nút mở dialog có thể đã bị xoá (ví dụ dialog "Xoá bài viết" xoá luôn cái nút).
sequenceDiagram participant U as Người dùng participant B as Nút Mở dialog participant D as Dialog participant T as useFocusTrap U->>B: Enter B->>D: mount D->>T: effect chạy T->>T: previouslyFocused = nút Mở dialog T->>D: focus ô Tên U->>D: Tab ở nút cuối T->>D: preventDefault, focus ô Tên U->>D: Esc D->>T: unmount, cleanup T->>B: focus lại nút Mở dialog
#Esc theo lớp
src/lib/focusTrap.ts
// ---- Esc chỉ đóng layer trên cùng ----
const escapeStack: Array<{ current: () => void }> = [];
let escapeListenerInstalled = false;
function onEscapeKeyDown(event: KeyboardEvent) {
if (event.key !== 'Escape' || escapeStack.length === 0) return;
// Đang gõ bằng bộ gõ IME (tiếng Nhật, tiếng Việt kiểu VNI/Telex trên một số bộ gõ…):
// Esc dùng để huỷ chữ đang soạn, không phải để đóng dialog.
if (event.isComposing) return;
// Một component bên trong (combobox, date picker…) đã tự xử lý Esc, vd: đóng dropdown
// của nó. Không đóng luôn cả dialog.
if (event.defaultPrevented) return;
event.preventDefault();
escapeStack[escapeStack.length - 1].current();
}
export function useEscapeLayer(onEscape: () => void, active = true) {
const handlerRef = useRef(onEscape);
useLayoutEffect(() => {
handlerRef.current = onEscape;
});
useEffect(() => {
if (!active) return;
if (!escapeListenerInstalled) {
document.addEventListener('keydown', onEscapeKeyDown);
escapeListenerInstalled = true;
}
// layer mở sau nằm trên cùng stack (giới hạn: xem bài viết)
escapeStack.push(handlerRef);
return () => {
const index = escapeStack.indexOf(handlerRef);
if (index !== -1) escapeStack.splice(index, 1);
};
}, [active]);
}
ts
- Một listener
keydownduy nhất cho mọi layer, gắn lười ở lần đầu cần dùng. escapeStackchứa ref chứ không chứa hàm, nên handler luôn là bản mới nhất mà không phải đăng ký lại.event.isComposing: Esc trong lúc soạn chữ IME là để huỷ chữ đang soạn. Đóng dialog lúc đó vừa sai vừa làm mất nội dung. (Facebook kiểm tra thêm cờcompositionstart/compositionendcho các trình duyệt bắn Esc saucompositionend. Nếu bạn nhắm tới nhiều người dùng IME, nên thêm cờ đó.)event.defaultPrevented: một combobox hoặc date picker trong dialog đã dùng Esc để đóng dropdown của nó và gọipreventDefault(). Dialog phải nhường. Cơ chế này hoạt động vì listener của ta nằm trêndocumentở pha bubble: React gắn listener ở root container (nằm bên trongdocument), nênonKeyDowncủa component chạy trước và kịppreventDefault(). Với portal render vàodocument.body, React cũng gắn listener lên container của portal, nên thứ tự vẫn đúng.- Giới hạn của cách dùng stack theo thứ tự mount: dialog cha và dialog con mount cùng một lần render thì effect của con chạy trước, con nằm dưới cha trong stack, và Esc đóng nhầm dialog cha. Thực tế dialog con hầu như luôn được mở sau (do người dùng bấm), nhưng nếu gặp trường hợp này thì cách của Facebook (tìm layer theo vị trí focus) mới là lời giải đúng.
Component dialog dùng 2 hook trên:
src/demos/FocusTrapDemo.tsx
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useEscapeLayer, useFocusTrap } from '../lib/focusTrap';
function Dialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const titleId = useId();
useFocusTrap(ref, true);
useEscapeLayer(onClose);
return createPortal(
<div className='backdrop' onClick={onClose}>
<div
ref={ref}
className='dialog'
role='dialog'
aria-modal='true'
aria-labelledby={titleId}
tabIndex={-1}
data-focus-trap
onClick={(e) => e.stopPropagation()}
>
<h2 id={titleId}>{title}</h2>
{children}
</div>
</div>,
document.body,
);
}
tsx
Dialog được render vào document.body bằng portal. Nhờ vậy nó nằm ngoài vùng nội dung chính, và có thể đặt inert cho vùng đó mà không "đóng băng" luôn dialog (xem phần bên dưới).
Xem toàn bộ src/lib/focusTrap.ts
src/lib/focusTrap.ts
import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react';
/**
* Giữ focus trong dialog + khôi phục focus khi đóng (ý tưởng từ FocusRegion / getTabbableNodes)
* và đóng đúng layer trên cùng khi bấm Esc (ý tưởng từ CometHideLayerOnEscape).
*/
const TABBABLE_SELECTOR = [
'a[href]',
'area[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'iframe',
'audio[controls]',
'video[controls]',
'summary',
'[contenteditable]:not([contenteditable="false"])',
'[tabindex]',
].join(',');
/** Các element Tab tới được, theo đúng thứ tự trong DOM. */
export function getTabbableNodes(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR)).filter((el) => {
if (el.tabIndex < 0) return false;
// nằm trong vùng `inert`, `fieldset` bị disable hay `<details>` đang đóng: trình duyệt
// không cho focus, nên cũng không được tính vào vòng Tab
if (el.closest('[inert]')) return false;
if (el.matches(':disabled')) return false;
const closedDetails = el.closest('details:not([open])');
if (closedDetails && !(el.tagName === 'SUMMARY' && el.parentElement === closedDetails)) {
return false;
}
// bị ẩn bằng display:none / kích thước 0 / visibility:hidden thì không focus được
if (el.offsetWidth === 0 && el.offsetHeight === 0 && el.getClientRects().length === 0) {
return false;
}
return getComputedStyle(el).visibility !== 'hidden';
});
}
export interface UseFocusTrapOptions {
/** focus vào phần tử đầu tiên khi mở, mặc định true */
autoFocus?: boolean;
/** trả focus về nơi mở dialog khi đóng, mặc định true */
restoreFocus?: boolean;
}
export function useFocusTrap(
containerRef: RefObject<HTMLElement | null>,
active: boolean,
{ autoFocus = true, restoreFocus = true }: UseFocusTrapOptions = {},
) {
// Ghi lại element đang focus NGAY LÚC RENDER lần đầu active (giống autoRestoreFocus của FB).
// Nếu đợi tới useEffect thì quá muộn: input con có `autoFocus` đã được React focus trong
// giai đoạn commit, và ta sẽ "nhớ nhầm" chính input đó.
// Lưu ý: đọc DOM và đọc/ghi ref trong lúc render là phá quy tắc của React (lint / React
// Compiler có thể cảnh báo). Ở đây chấp nhận được vì thao tác idempotent: render lại bao
// nhiêu lần thì kết quả vẫn như nhau.
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
const wasActiveRef = useRef(false);
if (active && !wasActiveRef.current && typeof document !== 'undefined') {
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
}
wasActiveRef.current = active;
useEffect(() => {
const container = containerRef.current;
if (!active || !container) return;
const previouslyFocused = previouslyFocusedRef.current;
if (autoFocus && !container.contains(document.activeElement)) {
// không có gì focus được thì focus chính container (cần tabIndex=-1)
(getTabbableNodes(container)[0] ?? container).focus();
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return;
// dialog lồng nhau: chỉ dialog chứa element đang focus mới xử lý
if (!container.contains(document.activeElement)) return;
const nodes = getTabbableNodes(container);
if (nodes.length === 0) {
event.preventDefault();
return;
}
const first = nodes[0];
const last = nodes[nodes.length - 1];
const current = document.activeElement;
if (event.shiftKey && (current === first || current === container)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && current === last) {
event.preventDefault();
first.focus();
}
};
// Focus lọt ra ngoài (click ra nền, công cụ trợ năng…) thì kéo lại vào trong.
// Bỏ qua nếu focus đi vào một dialog khác mở chồng lên trên.
const onFocusIn = (event: FocusEvent) => {
const target = event.target as Node;
if (container.contains(target)) return;
if (target instanceof Element && target.closest('[data-focus-trap]')) return;
(getTabbableNodes(container)[0] ?? container).focus();
};
document.addEventListener('keydown', onKeyDown);
document.addEventListener('focusin', onFocusIn);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('focusin', onFocusIn);
// chỉ trả focus nếu element cũ vẫn còn trên trang
if (restoreFocus && previouslyFocused?.isConnected) previouslyFocused.focus();
};
}, [active, autoFocus, restoreFocus, containerRef]);
}
// ---- Esc chỉ đóng layer trên cùng ----
const escapeStack: Array<{ current: () => void }> = [];
let escapeListenerInstalled = false;
function onEscapeKeyDown(event: KeyboardEvent) {
if (event.key !== 'Escape' || escapeStack.length === 0) return;
// Đang gõ bằng bộ gõ IME (tiếng Nhật, tiếng Việt kiểu VNI/Telex trên một số bộ gõ…):
// Esc dùng để huỷ chữ đang soạn, không phải để đóng dialog.
if (event.isComposing) return;
// Một component bên trong (combobox, date picker…) đã tự xử lý Esc, vd: đóng dropdown
// của nó. Không đóng luôn cả dialog.
if (event.defaultPrevented) return;
event.preventDefault();
escapeStack[escapeStack.length - 1].current();
}
export function useEscapeLayer(onEscape: () => void, active = true) {
const handlerRef = useRef(onEscape);
useLayoutEffect(() => {
handlerRef.current = onEscape;
});
useEffect(() => {
if (!active) return;
if (!escapeListenerInstalled) {
document.addEventListener('keydown', onEscapeKeyDown);
escapeListenerInstalled = true;
}
// layer mở sau nằm trên cùng stack (giới hạn: xem bài viết)
escapeStack.push(handlerRef);
return () => {
const index = escapeStack.indexOf(handlerRef);
if (index !== -1) escapeStack.splice(index, 1);
};
}, [active]);
}
ts
#Demo
Mở dialog, bấm Tab / Shift+Tab nhiều lần (nút disabled và nút ẩn được bỏ qua). Mở dialog xác nhận rồi bấm Esc hai lần, xem focus quay về đâu ở ô "Đang focus".
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Tại sao không làm cách khác?
"Thêm hai div tabindex=0 ở hai đầu dialog (focus sentinel), focus vào đó thì vòng lại?" Đây là cách của nhiều thư viện, và nó tốt vì không cần nghe keydown. Điểm yếu thật của nó nằm ở chỗ khác: trình đọc màn hình di chuyển bằng virtual cursor có thể "đứng" lên sentinel (một phần tử rỗng, khó hiểu), và nếu chỉ có sentinel ở một đầu thì Tab từ phần tử cuối sẽ thoát ra thanh địa chỉ của trình duyệt. Dù dùng cách nào, vẫn nên có lưới an toàn focusin.
"Chỉ cần aria-modal="true" là đủ?" Không. aria-modal chỉ là gợi ý cho trình đọc màn hình, nó không chặn Tab và không chặn click.
"Sao không dùng inert cho toàn bộ trang rồi khỏi cần focus trap?" Đây là hướng đúng cho modal hiện đại. inert trên phần nội dung chính chặn Tab, click và virtual cursor của trình đọc màn hình. Khi có inert, phần logic Tab trong hook gần như không còn cần thiết. Khác biệt duy nhất: Tab từ phần tử cuối của dialog sẽ đi ra thanh địa chỉ/giao diện trình duyệt thay vì vòng về đầu. Điều đó chấp nhận được (<dialog> gốc cũng vậy). Bạn vẫn cần auto focus, trả focus khi đóng và Esc theo lớp. inert đã có trong mọi trình duyệt hiện đại (tính tới 10/2026).
"Sao không lưu document.activeElement trong effect cho gọn?" Vì không đủ sớm. Nếu một input trong dialog dùng prop autoFocus của React, React gọi focus() trong lúc commit, trước khi effect chạy. Lúc effect chạy, activeElement đã nằm trong dialog, nên bị lưu nhầm, và khi đóng thì focus không về được nút mở. Facebook tránh chuyện này bằng cách lưu activeElement ngay lúc render (useRef(document.activeElement)), và bản viết lại làm theo: đọc activeElement trong thân hook ở lần render đầu tiên active chuyển thành true. Thẳng thắn mà nói, cách này phá quy tắc của React: đọc DOM và đọc/ghi ref (wasActiveRef.current = active) ngay trong lúc render, lint hoặc React Compiler có thể cảnh báo. Ta chấp nhận vì thao tác idempotent: React render lại bao nhiêu lần thì kết quả vẫn như nhau.
#Edge case và cạm bẫy
Shadow DOM. querySelectorAll không đi xuyên shadow root, và document.activeElement chỉ trả về shadow host. Nếu dialog chứa web component (ví dụ một date picker viết bằng Lit), node bên trong không có trong danh sách tabbable. Cần đi xuống el.shadowRoot và dùng activeElement của shadow root.
Iframe. Khi focus đang ở trong iframe, sự kiện keydown thuộc về document của iframe, document cha không nhận được. Nếu iframe là node cuối của dialog, Tab từ trong iframe có thể thoát ra. Lưới focusin sẽ kéo focus lại, nhưng có một lần nhảy thấy được.
Portal và vùng inert. Đặt inert lên #root trong khi dialog được portal vào #root thì dialog cũng bị inert theo. Hãy portal dialog ra ngoài vùng bị inert (vào document.body, như demo), hoặc chỉ đặt inert cho phần nội dung chính chứ không đặt cho container chung.
Toast và live region bên ngoài dialog. Một toast có nút "Hoàn tác" (bài #4) nằm ngoài dialog thì không Tab tới được khi dialog mở. Đây thường là hành vi đúng. Nếu cần tương tác được, đánh dấu vùng toast bằng data-focus-trap để trap bỏ qua.
tabindex dương. Trap tính "node đầu/cuối" theo thứ tự DOM, trong khi trình duyệt đưa element có tabindex="1" lên trước. Đừng dùng tabindex dương. Nếu bắt buộc phải dùng, hãy chuyển sang cách của FocusRegion: tự tính và focus node kế tiếp ở mọi lần Tab.
StrictMode ở dev. React chạy effect, dọn dẹp rồi chạy lại một lần khi mount. Lần dọn dẹp trả focus về nút mở rồi lần chạy lại kéo focus vào dialog, nên trình đọc màn hình có thể đọc hai lần ở môi trường dev. Production không bị.
Element bị xoá khi đang focus. Xoá item đang focus (như comment) thì focus rơi về body. Học recoverFocusQuery của Facebook: trước khi xoá, ghi nhớ vị trí, xoá xong thì focus node gần nhất.
#So với thư viện
- focus-trap (dùng bởi
focus-trap-react): thư viện vanilla JS, dùng tabbable để tính danh sách node, có hỗ trợ nhiều trap lồng nhau (tự tạm dừng trap cũ khi trap mới kích hoạt), tuỳ chọn trả focus, click ra ngoài. - react-focus-lock: dùng sentinel và theo dõi focus, xử lý tốt portal và nhiều lock.
- Radix UI / React Aria: component dialog hoàn chỉnh. Ngoài focus trap còn làm phần ngoài dialog thành ẩn với trình đọc màn hình, khoá scroll trang và quản lý layer cho Esc.
<dialog>+showModal(): trình duyệt tự làm nền thành inert, đóng bằng Esc, có pseudo-element::backdrop. Nếu dự án cho phép, đây là lựa chọn mặc định nên cân nhắc trước tiên.
Bạn vẫn cần hiểu kỹ thuật trong bài khi làm popover, menu, combobox không phải modal (không dùng showModal được) nhưng vẫn cần quản lý focus và Esc theo lớp, hoặc khi phải debug các thư viện kể trên.
Tip
Để chặn tương tác với phần nền khi mở modal tự viết, đặt thuộc tính inert lên vùng nội dung chính. Nó chặn cả click, focus lẫn trình đọc màn hình, đơn giản hơn nhiều so với tự lọc từng event.
#Kiểm thử
Logic focus phụ thuộc layout thật (offsetWidth, getComputedStyle), nên jsdom không đáng tin: trong jsdom mọi element đều có kích thước 0. Hãy test bằng trình duyệt thật, ví dụ Playwright chạy với demo ở trên.
Ví dụ test (bản phác, không có trong repo)
focus-trap.spec.ts
import { expect, test } from '@playwright/test';
test('Tab vòng trong dialog, Esc đóng từng lớp và trả focus', async ({ page }) => {
await page.goto('/demos/facebook-patterns/index.html#/focus-trap');
const opener = page.getByRole('button', { name: 'Mở dialog', exact: true });
await opener.click();
const dialog = page.getByRole('dialog', { name: 'Chỉnh sửa trang cá nhân' });
await expect(dialog.getByLabel('Tên')).toBeFocused();
// Tab qua hết các node rồi phải quay về ô đầu tiên
for (let i = 0; i < 4; i++) await page.keyboard.press('Tab');
await expect(dialog.getByLabel('Tên')).toBeFocused();
await dialog.getByRole('button', { name: 'Mở dialog xác nhận' }).click();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(1); // chỉ dialog trên cùng đóng
await expect(dialog.getByRole('button', { name: 'Mở dialog xác nhận' })).toBeFocused();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(opener).toBeFocused();
});
ts
Nên chạy thêm bằng trình đọc màn hình thật (VoiceOver trên macOS: Cmd + F5) ít nhất một lần: mở dialog, dùng VO + mũi tên để xem có "lọt" ra nội dung phía sau không. Đây là thứ không test tự động nào bắt được.
Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Phần 1
- Mở đầu: cách đọc code minify của Facebook
- #1 Dùng chung IntersectionObserver + infinite scroll
- #2 useViewportDuration: đo thời gian thực sự được xem
- #3 Preload code + data khi hover
- #4 Toast manager với external store
- #5 Network status + heartbeat backoff
- #6 ErrorGuard: bắt lỗi theo ngữ cảnh
- #7 Banzai: gom log theo lô + sendBeacon
- #8 Focus trap + Esc theo layer (bài này)
Phần 2
- #9 Mẹo calc(… * 9999): container query, line-clamp, mũi tên carousel bằng CSS
- #10 Thanh cuộn tuỳ biến bằng CSS 3D
- #11 Modal cuộn bằng window, giữ vị trí trang nền
- #12 Popover, tooltip group và hovercard
- #13 Primitive Pressable + focus-visible
- #14 Tab "Xem thêm" + sticky header bằng IntersectionObserver
- #15 Avatar khoét mask, gradient Messenger, skeleton steps()
- #16 Khung chat không giật
- #17 Thuật toán chọn bố cục collage ảnh
- #18 Keep-alive tab trong SPA
- #19 Tự đo Visual Completion & interaction tracing
- #20 Router SPA bền bỉ & tải chunk có retry
Phần 3
- #21 Smart Compose: gợi ý chữ mờ khi gõ
- #22 MQTT ngay trong trình duyệt, tự chuyển sang long-poll
- #23 Đo thời gian dùng bằng bitmap 64 giây
- #24 Bảng reaction nhấn giữ rồi trượt
- #25 Tìm kiếm gấp dấu, phiên âm và highlight đầu từ
- #26 Ring buffer biết trace nào đã mất log
- #27 Engine animation JS: một vòng rAF và solver cubic-bezier
- #28 Bootloader: bitmap tài nguyên đã tải, chỉ reload khi cần
- #29 Nhận biết click do screen reader tạo ra
- #30 Tải trước feed theo chất lượng mạng, đoán hạng thiết bị
- #31 Hàng đợi gửi tin chat giữ đúng thứ tự
- #32 Gộp lời gọi API trong cùng một tick
- #33 Resize ảnh trong pool Web Worker
- #34 Crop ảnh theo nhiều khuôn mặt
- #35 Kéo-thả sắp xếp và vùng thả file không nhấp nháy
- #36 Tiện ích UI: fade-out, sticky event, hàm huỷ được
- #37 Hạ tầng nhỏ: cookie, hash lỗi, prefetch khi chuột dừng
Phần 4–6
- #38 Đồng bộ tin nhắn: phát hiện lỗ seqID và fetch bù theo tầng
- #39 OTID: ID tin nhắn kiểu Snowflake sinh ở client
- #40 QPL: lấy mẫu từ lúc bắt đầu, đo RTT gửi tin đầu-cuối
- #41 Bộ điều phối request bảy chế độ gộp và chống trùng
- #42 Thread list theo mốc thời gian, cookie presence có version
- #43 Composer dạng plugin: reducer, dirty checker, chuỗi kiểm tra
- #44 Thanh toán bất đồng bộ: subscribe trước, poll dự phòng
- #45 Validate số thẻ: sửa lỗi gõ, dải BIN, Luhn
- #46 Lịch chọn khoảng ngày: bo góc dải chọn, phím đủ bộ, RTL
- #47 Giờ mở cửa theo tuần: qua đêm, gộp khoảng, phần bù
- #48 Tiến độ đăng realtime: giữ event chờ snapshot, watchdog
- #49 Tiền tệ theo đơn vị nhỏ nhất và ô nhập tiền
- #50 Sửa hàng loạt Empty/Uniform/Mixed, seen/read bằng 2 bit
- #51 Phát hiện script lạ hook vào hàm native
- #52 localStorage tự dọn khi đầy quota
- #53 Cache tuyến do server phân giải, chờ sẵn promise
- #54 Lưới phím mũi tên 2D và focus không làm cuộn trang
- #55 Tách emoji bằng máy trạng thái
- #56 Đọc trước ghi sau trong một frame, con tự biết vị trí
- #57 Hàng đợi lệnh cho video và stream sắp lại chunk
Phần 7–10
- #58 Ước lượng băng thông EWMA: loại outlier, nhớ qua phiên
- #59 ABR theo độ tự tin phát và cắt bậc bitrate theo MOS
- #60 Prefetch segment dự đoán từ manifest, parse hộp sidx
- #61 Đọc metadata MP4/MOV ngay trên client
- #62 Upload tiếp tục được: offset, hash dedupe, phát hiện treo
- #63 Fenwick tree: chọn ngẫu nhiên có trọng số và offset dòng
- #64 Vạch trục số và trục thời gian "đẹp"
- #65 Damerau-Levenshtein theo dải chéo và tự sửa tên gõ sai
- #66 PRNG có seed, shuffle tái lập và lấy mẫu trace
- #67 Cặp min/max-heap tái dùng vị trí trong danh sách ảo
- #68 Memo nhiều đối số bằng Map/WeakMap và interning object
- #69 Thống kê cửa sổ trượt và đại số khoảng thời gian
- #70 Int64 từ hai số 32-bit, decode UTF, offset theo code point
- #71 Engine múi giờ IANA trên client, phát hiện lệch giờ
- #72 Tự cài semver range
- #73 Auto-link URL, cắt chữ an toàn, giới hạn LaTeX
- #74 Parser query kiểu PHP chống prototype pollution
- #75 Popover tự lật theo phần tràn nhỏ nhất
- #76 Tiền tố for(;😉 chống JSON hijacking, đọc stream
- #77 Chống lộ Referer, linkshim, xoá token trên URL
- #78 setImmediate bằng postMessage, gom delta chuột theo frame
- #79 Phát hiện crash qua heartbeat, dò proxy cache hỏng
- #80 DPR vào cookie cho ảnh @2x, che khoá trong log
- #81 Flux waitFor tự viết và proxy có đệm
- #82 Crop đúng tỉ lệ, xoay sticker bằng ma trận
Phần 11–15
- #83 Chia subdomain giữa các tab bằng lease, long-poll theo bảng trạng thái
- #84 MQTT QoS1: giữ publish chờ ack, gửi lại khi kết nối lại
- #85 Cache Promise gộp request, cache lỗi ngắn, đua dữ liệu nhúng sẵn
- #86 Lịch retry: backoff có trần, Fibonacci, báo lỗi CDN mỗi N ngày
- #87 Giữ nguyên tham chiếu nhánh dữ liệu không đổi
- #88 Store optimistic: copy-on-write, rollback rồi áp lại
- #89 Chỉ mục subscription theo record, hàng đợi task theo chiều sâu
- #90 Phân trang connection: cursor khớp đầu, edge không trùng
- #91 Profiler kiểu middleware và memo selector có lấy mẫu kiểm tra
- #92 Đọc EBML/WebM Cues để tìm byte range của segment
- #93 Stream HTTP Range tạm dừng được, tải tiếp đúng byte
- #94 Bảng khoảng thời gian gắn nhãn và nhảy qua lỗ buffer
- #95 Gom chunk cho SourceBuffer, đồng bộ video bằng playbackRate
- #96 Gom tin nhắn thành cụm, vạch ngày và avatar đã xem
- #97 Bao nhiêu tab chat vừa màn hình, chuỗi luật mở tab
- #98 Phím tắt Markdown trong editor, Backspace để hoàn tác
- #99 Offset theo code point hay UTF-16: bốn bug cùng một gốc
- #100 Typeahead không nhảy: cache tiền tố, chặn reflow, khoá hover
- #101 Hit-test trên canvas bằng phát lại path, nét đứt và cung Bézier
- #102 Vòng tròn theo mét trên bản đồ Mercator
- #103 Dò tính năng trình duyệt: ảnh EXIF 2×1, getter bẫy, scrollbar
- #104 Bug định dạng số và kiểm tra dữ liệu theo locale
- #105 Dispose cho đúng: idle callback, splice(-1), xoá nhầm key
Code + demo: examples/facebook-patterns · Mở trên StackBlitz

