Web Totals

Học từ Facebook #54: Lưới phím mũi tên 2D, focus không cuộn trang

FocusTable của Facebook cho lưới điều hướng bằng mũi tên có ô gộp cột, RTL, PageUp/Down và roving tabindex; FocusManager focus bằng code mà trang không nhảy.

hunghg255hunghg25527 min read
#facebook#a11y#keyboard#focus#react
Học từ Facebook #54: Lưới phím mũi tên 2D, focus không cuộn trang

Lịch tháng, bảng emoji, danh sách kênh dạng lưới: người dùng bàn phím mong bấm mũi tên là đi sang ô bên cạnh, bấm Tab là thoát khỏi cả lưới chứ không phải Tab qua 42 ô. Khó hơn khi lưới có ô gộp cột (sự kiện kéo dài 3 ngày), có hàng bị khoá, và trang chạy ở chế độ phải-sang-trái (tiếng Ả Rập, Do Thái).

Việc thứ hai hay gặp: focus một phần tử bằng code (trả focus sau khi đóng dialog, focus ô đầu khi mở menu) mà trang không bị cuộn giật, và các nút chỉ hiện khi người dùng tự focus thì không bật lên.

Tip

Tóm tắt

  • Lưới 2D: tính cột thật của ô bằng cách cộng colSpan các ô đứng trước; đi lên/xuống giữ cột thật đó, hàng ngắn hơn thì về ô cuối.
  • Roving tabindex: chỉ một ô có tabIndex=0, mũi tên đổi ô, Tab đi thẳng ra khỏi lưới. RTL thì đổi vai trò mũi tên trái/phải.
  • Focus không cuộn: phát hiện focus({ preventScroll }) bằng getter; trình duyệt cũ thì lưu scrollTop mọi tổ tiên đang tràn rồi trả lại.
  • Cờ "focus do code" (isFocusingWithoutUserIntent) để UI phân biệt focus của người dùng với focus của chương trình.

#Facebook làm thế nào?

#FocusTable: bảng → hàng → ô

FocusTable.react (trong bundle 262.js) là một factory: createFocusTable(scopeQuery) trả về ba component [Table, Row, Cell]. Mỗi tầng là một React.unstable_Scope (API nội bộ của React cho phép truy vấn các node DOM bên trong một vùng) cộng một context. Ô (Cell) đăng ký { scopeRef, disabled, colSpan, tag } vào context của hàng; hàng đăng ký vào bảng. Khi một ô nhận keydown, nó hỏi bảng để biết mình đang ở đâu.

Phần quan trọng nhất là cột thật. Giải mã hàm r() (đổi tên biến):

// trả về [chỉ số ô trong hàng, cột bắt đầu thật]
function indexAndColumn(cells, scope) {
  let extra = 0;
  for (let i = 0; i < cells.length; i++) {
    if (cells[i].scopeRef.current === scope) return [i, i + extra];
    extra += (cells[i].colSpan ?? 1) - 1; // ô trước chiếm thêm bao nhiêu cột
  }
  return [-1, -1];
}
js

Đi xuống hàng dưới, Facebook tìm ô phủ cột đó: cộng dồn colSpan cho tới khi vượt quá cột cần tìm (o()), không có thì về ô cuối hàng (p()):

function cellAtColumn(row, col) {
  let end = 0;
  for (const cell of enabledCells(row)) {
    end += cell.colSpan || 1;
    if (end > col) return cell;
  }
  return null; // nơi gọi sẽ focus ô cuối hàng
}
js

Các phím được xử lý trong một switch lớn: Home/End (có Ctrl thì đi đầu/cuối bảng), PageUp/PageDown nhảy pageJumpSize hàng (mặc định j = 5), wrapX, wrapY, wrapXToNextLine. Khi Locale.isRTL(), ArrowLeft và ArrowRight được đổi chỗ trước khi vào switch. Hàng có tất cả ô disabled bị lọc khỏi danh sách hàng (u()), nên mũi tên nhảy qua.

Với Tab, mỗi lần bấm, FocusTable chỉ để đúng phần tử đang focus là tabbable (setElementCanTab(el, true)), mọi phần tử khác trong bảng bị đặt không tabbable. Đó là roving tabindex (tabindex luân chuyển): cả lưới chỉ chiếm một điểm dừng Tab.

Có thêm một lối thoát tinh tế: prop onNavigate nhận { type, currentRowIndex, currentCellIndex, getCellByTag, focusCell, preventDefault }. Component bên ngoài có thể chặn một hướng hoặc tự focus ô khác. getCellByTag(tag) tìm ô có nhãn cho trước, xoè dần từ cột hiện tại sang hai bên (b-f, b+f), rồi tìm ở các hàng sau, quay vòng.

#FocusManager: focus mà không cuộn

FocusManager.react (bundle 26.js) bọc mọi lời gọi focus của Comet. Khi cần focus mà không cuộn, nó phải biết trình duyệt có hiểu focus({ preventScroll: true }) không. Cách phát hiện:

function detect() {
  try {
    const div = document.createElement('div');
    div.addEventListener('focus', (e) => { e.preventDefault(); e.stopPropagation(); }, true);
    div.focus(Object.defineProperty({}, 'preventScroll', {
      get() { supported = true; },
    }));
  } catch {}
}
js

Trình duyệt nào hỗ trợ option thì phải đọc thuộc tính preventScroll khi chuyển object sang FocusOptions (bước chuyển kiểu theo WebIDL chạy trước cả việc kiểm tra phần tử có focus được không), getter chạy và cờ bật. Trình duyệt cũ bỏ qua đối số, getter không bao giờ chạy. Đây là cùng mẹo với cách phát hiện { passive: true } của addEventListener.

Không hỗ trợ thì Facebook làm thủ công: đi ngược lên cây, tổ tiên nào có offsetHeight < scrollHeight hoặc offsetWidth < scrollWidth thì lưu [node, scrollTop, scrollLeft], cộng thêm document.scrollingElement; focus; rồi gán lại các giá trị đã lưu.

Hàm focusElement còn làm hai việc nữa:

  • Đặt tạm tabIndex = -1 trong lúc focus rồi trả lại giá trị cũ; phần tử có _tabIndexState.canTab === false thì không focus.
  • Bật cờ g trong lúc gọi focus, đọc qua isFocusingWithoutUserIntent(). useMenuButtonVisibilityState (bundle 87.js) dùng cờ này: nút "…" của một dòng chỉ hiện khi hover hoặc người dùng focus vào dòng; focus do code (trả focus sau khi đóng dialog) thì không làm nút bật lên.
flowchart TD
  K["keydown trên một ô"] --> R{"RTL?"}
  R -- có --> S["đổi ArrowLeft ⇄ ArrowRight"]
  R -- không --> C
  S --> C["tính cột thật = chỉ số ô + tổng colSpan-1 phía trước"]
  C --> N{"onNavigate chặn?"}
  N -- có --> X["dừng"]
  N -- không --> M["chọn hàng đích: bỏ hàng toàn disabled, wrap, PageUp/Down 5 hàng"]
  M --> F["ô phủ cột thật, không có thì ô cuối hàng"]
  F --> T["roving tabindex + focusElement"]

#Viết lại

Bản viết lại tách phần tính toán ra khỏi React: lưới là mảng 2 chiều GridCell[][] ({ el, colSpan, disabled, tag }), hàm nextPosition nhận vị trí hiện tại và phím, trả về vị trí mới. Cách này test được không cần DOM, và gắn được vào bất kỳ framework nào.

Hai hàm nền: cột bắt đầu của một ô, và ô phủ một cột.

src/lib/focusTable.ts
  let col = 0;
  for (let i = 0; i < index; i++) col += row[i].colSpan ?? 1;
  return col;
}

/** Ô phủ cột `col`. Hàng ngắn hơn thì trả ô cuối (giống Facebook). */
export function cellIndexAtColumn(row: GridCell[], col: number, tag?: string) {
  let end = 0;
  for (let i = 0; i < row.length; i++) {
    end += row[i].colSpan ?? 1;
    if (end > col && (tag === undefined || row[i].tag === tag)) return i;
  }
  return tag === undefined && row.length > 0 ? row.length - 1 : -1;
}
ts

Bảng chuyển phím (sau khi đã đổi trái/phải cho RTL và tính col):

src/lib/focusTable.ts
  switch (k) {
    case 'ArrowRight':
      if (cellIdx < row.length - 1) return at(rowIdx, cellIdx + 1);
      if (wrapX) return at(rowIdx, 0);
      if (wrapXToNextLine && rowIdx < last) return at(rowIdx + 1, 0);
      return null;
    case 'ArrowLeft':
      if (cellIdx > 0) return at(rowIdx, cellIdx - 1);
      if (wrapX) return at(rowIdx, row.length - 1);
      if (wrapXToNextLine && rowIdx > 0) return at(rowIdx - 1, rows[rowIdx - 1].row.length - 1);
      return null;
    case 'ArrowDown':
      if (ctrl) return rowIdx === last ? null : sameColumn(last);
      if (rowIdx < last) return sameColumn(rowIdx + 1);
      return wrapY ? sameColumn(0) : null;
    case 'ArrowUp':
      if (ctrl) return rowIdx === 0 ? null : sameColumn(0);
      if (rowIdx > 0) return sameColumn(rowIdx - 1);
      return wrapY ? sameColumn(last) : null;
    case 'PageDown':
      return rowIdx === last ? null : sameColumn(Math.min(last, rowIdx + pageJumpSize));
    case 'PageUp':
      return rowIdx === 0 ? null : sameColumn(Math.max(0, rowIdx - pageJumpSize));
    case 'Home':
      if (ctrl) return rowIdx === 0 && cellIdx === 0 ? null : at(0, 0);
      return cellIdx === 0 ? null : at(rowIdx, 0);
    case 'End':
      if (ctrl) {
        const lastRow = rows[last].row;
        return rowIdx === last && cellIdx === row.length - 1 ? null : at(last, lastRow.length - 1);
      }
      // Bản gốc so chỉ số với chính object ô (luôn khác) nên End luôn focus lại; ở đây so chỉ số.
      return cellIdx === row.length - 1 ? null : at(rowIdx, row.length - 1);
  }
}
ts

Phần focus không cuộn, với supportsPreventScroll() và snapshotScroll() như mô tả ở trên:

src/lib/focusTable.ts
export function focusElement(el: HTMLElement, { preventScroll = false, strategy = 'auto' }: FocusOptionsEx = {}) {
  const prev = programmaticFocus;
  programmaticFocus = true;
  try {
    if (!preventScroll) el.focus();
    else if (strategy !== 'fallback-only' && supportsPreventScroll()) el.focus({ preventScroll: true });
    else if (strategy === 'native-only') el.focus();
    else {
      const restore = snapshotScroll(el);
      el.focus();
      restore();
    }
  } finally {
    programmaticFocus = prev;
  }
}
ts
Xem toàn bộ src/lib/focusTable.ts
src/lib/focusTable.ts
/**
 * Lưới điều hướng bằng phím mũi tên 2D (ý tưởng từ FocusTable.react của Facebook)
 * và focus không làm cuộn trang (ý tưởng từ FocusManager.react).
 *
 * - Ô có colSpan: đi lên/xuống giữ đúng CỘT thật, không phải chỉ số trong hàng.
 * - RTL: ArrowLeft/ArrowRight đổi vai trò cho nhau.
 * - Home/End trong hàng, Ctrl+Home/End về đầu/cuối bảng, PageUp/PageDown nhảy 5 hàng.
 * - Hàng toàn ô disabled bị bỏ qua.
 * - Roving tabindex: chỉ một ô có tabIndex=0, Tab đi thẳng ra khỏi lưới.
 */

export interface GridCell {
  el: HTMLElement;
  /** số cột ô chiếm, mặc định 1 */
  colSpan?: number;
  disabled?: boolean;
  /** nhãn để tìm ô theo loại (vd 'action'), giống getCellByTag */
  tag?: string;
}

export interface GridPosition {
  row: number;
  /** chỉ số ô trong hàng (không phải cột) */
  index: number;
}

export interface GridOptions {
  rtl?: boolean;
  /** ArrowLeft ở ô đầu → ô cuối cùng hàng (và ngược lại) */
  wrapX?: boolean;
  /** ArrowUp ở hàng đầu → hàng cuối (và ngược lại) */
  wrapY?: boolean;
  /** ArrowRight ở ô cuối → ô đầu của hàng sau */
  wrapXToNextLine?: boolean;
  /** số hàng PageUp/PageDown nhảy, mặc định 5 như Facebook */
  pageJumpSize?: number;
}

export type GridKey = 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'Home' | 'End' | 'PageUp' | 'PageDown';

const enabled = (row: GridCell[]) => row.filter((cell) => !cell.disabled);

/** Hàng có ít nhất một ô dùng được. Facebook bỏ hẳn hàng toàn ô disabled khỏi việc điều hướng. */
function usableRows(grid: GridCell[][]) {
  return grid.map((row, i) => ({ row: enabled(row), original: i })).filter((r) => r.row.length > 0);
}

/** Cột bắt đầu của ô thứ `index`: cộng colSpan của các ô đứng trước. */
export function startColumn(row: GridCell[], index: number) {
  let col = 0;
  for (let i = 0; i < index; i++) col += row[i].colSpan ?? 1;
  return col;
}

/** Ô phủ cột `col`. Hàng ngắn hơn thì trả ô cuối (giống Facebook). */
export function cellIndexAtColumn(row: GridCell[], col: number, tag?: string) {
  let end = 0;
  for (let i = 0; i < row.length; i++) {
    end += row[i].colSpan ?? 1;
    if (end > col && (tag === undefined || row[i].tag === tag)) return i;
  }
  return tag === undefined && row.length > 0 ? row.length - 1 : -1;
}

/**
 * Tính ô kế tiếp cho một phím. Làm việc trên ô "dùng được" (đã lọc disabled),
 * trả về vị trí theo chỉ số GỐC trong `grid` để gọi focus.
 */
export function nextPosition(
  grid: GridCell[][],
  current: GridPosition,
  key: GridKey,
  { ctrl = false }: { ctrl?: boolean } = {},
  options: GridOptions = {},
): GridPosition | null {
  const { rtl = false, wrapX = false, wrapY = false, wrapXToNextLine = false, pageJumpSize = 5 } = options;
  const rows = usableRows(grid);
  const rowIdx = rows.findIndex((r) => r.original === current.row);
  if (rowIdx === -1) return null;
  const row = rows[rowIdx].row;
  const cellIdx = row.indexOf(grid[current.row][current.index]);
  if (cellIdx === -1) return null;
  const col = startColumn(row, cellIdx);

  // Trong giao diện phải-sang-trái, mũi tên trái đi tới ô "kế tiếp"
  let k: GridKey = key;
  if (rtl && key === 'ArrowLeft') k = 'ArrowRight';
  else if (rtl && key === 'ArrowRight') k = 'ArrowLeft';

  const at = (r: number, i: number): GridPosition => {
    const target = rows[r].row[i];
    return { row: rows[r].original, index: grid[rows[r].original].indexOf(target) };
  };
  const sameColumn = (r: number) => at(r, cellIndexAtColumn(rows[r].row, col));
  const last = rows.length - 1;

  switch (k) {
    case 'ArrowRight':
      if (cellIdx < row.length - 1) return at(rowIdx, cellIdx + 1);
      if (wrapX) return at(rowIdx, 0);
      if (wrapXToNextLine && rowIdx < last) return at(rowIdx + 1, 0);
      return null;
    case 'ArrowLeft':
      if (cellIdx > 0) return at(rowIdx, cellIdx - 1);
      if (wrapX) return at(rowIdx, row.length - 1);
      if (wrapXToNextLine && rowIdx > 0) return at(rowIdx - 1, rows[rowIdx - 1].row.length - 1);
      return null;
    case 'ArrowDown':
      if (ctrl) return rowIdx === last ? null : sameColumn(last);
      if (rowIdx < last) return sameColumn(rowIdx + 1);
      return wrapY ? sameColumn(0) : null;
    case 'ArrowUp':
      if (ctrl) return rowIdx === 0 ? null : sameColumn(0);
      if (rowIdx > 0) return sameColumn(rowIdx - 1);
      return wrapY ? sameColumn(last) : null;
    case 'PageDown':
      return rowIdx === last ? null : sameColumn(Math.min(last, rowIdx + pageJumpSize));
    case 'PageUp':
      return rowIdx === 0 ? null : sameColumn(Math.max(0, rowIdx - pageJumpSize));
    case 'Home':
      if (ctrl) return rowIdx === 0 && cellIdx === 0 ? null : at(0, 0);
      return cellIdx === 0 ? null : at(rowIdx, 0);
    case 'End':
      if (ctrl) {
        const lastRow = rows[last].row;
        return rowIdx === last && cellIdx === row.length - 1 ? null : at(last, lastRow.length - 1);
      }
      // Bản gốc so chỉ số với chính object ô (luôn khác) nên End luôn focus lại; ở đây so chỉ số.
      return cellIdx === row.length - 1 ? null : at(rowIdx, row.length - 1);
  }
}

/**
 * Tìm ô có `tag`, ưu tiên gần cột hiện tại: xoè dần sang trái/phải trong hàng hiện tại,
 * rồi các hàng phía sau (quay vòng). Giống getCellByTag của Facebook.
 */
export function findCellByTag(grid: GridCell[][], current: GridPosition, tag: string): GridPosition | null {
  const rows = usableRows(grid);
  const start = Math.max(0, rows.findIndex((r) => r.original === current.row));
  const cur = grid[current.row][current.index];
  const col = startColumn(enabled(grid[current.row]), enabled(grid[current.row]).indexOf(cur));
  for (let step = 0; step < rows.length; step++) {
    const { row, original } = rows[(start + step) % rows.length];
    // cột cần xét lần lượt: col, col-1, col+1, col-2, col+2…
    const width = startColumn(row, row.length);
    for (let d = 0; d <= width; d++) {
      for (const c of d === 0 ? [col] : [col - d, col + d]) {
        if (c < 0 || c >= width) continue;
        const i = cellIndexAtColumn(row, c, undefined);
        if (row[i]?.tag === tag) return { row: original, index: grid[original].indexOf(row[i]) };
      }
    }
  }
  return null;
}

/** Roving tabindex: chỉ ô đang active nhận Tab, các ô khác tabIndex = -1. */
export function applyRovingTabIndex(grid: GridCell[][], active: GridPosition) {
  grid.forEach((row, r) =>
    row.forEach((cell, i) => {
      cell.el.tabIndex = r === active.row && i === active.index && !cell.disabled ? 0 : -1;
    }),
  );
}

// ---- Focus không làm cuộn trang ----

let preventScrollSupported: boolean | null = null;

/**
 * Phát hiện `focus({ preventScroll })` bằng getter: trình duyệt nào hiểu option
 * thì phải ĐỌC thuộc tính, getter chạy và ta biết. Chạy lười, một lần.
 */
export function supportsPreventScroll() {
  if (preventScrollSupported !== null) return preventScrollSupported;
  preventScrollSupported = false;
  try {
    const probe = document.createElement('div');
    probe.focus(
      Object.defineProperty({}, 'preventScroll', {
        get() {
          preventScrollSupported = true;
          return true;
        },
      }) as FocusOptions,
    );
  } catch {
    // trình duyệt rất cũ ném lỗi khi nhận object lạ: coi như không hỗ trợ
  }
  return preventScrollSupported;
}

/** Ghi lại vị trí cuộn của mọi tổ tiên đang tràn + document, để trả lại sau khi focus. */
function snapshotScroll(el: HTMLElement) {
  const saved: Array<[Element, number, number]> = [];
  const root = document.scrollingElement ?? document.documentElement;
  for (let node = el.parentElement; node && node !== root; node = node.parentElement) {
    if (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth) {
      saved.push([node, node.scrollTop, node.scrollLeft]);
    }
  }
  saved.push([root, root.scrollTop, root.scrollLeft]);
  return () => {
    for (const [node, top, left] of saved) {
      node.scrollTop = top;
      node.scrollLeft = left;
    }
  };
}

let programmaticFocus = false;

/** true khi focus hiện tại do code gọi (không phải người dùng bấm/Tab). */
export function isProgrammaticFocus() {
  return programmaticFocus;
}

export interface FocusOptionsEx {
  preventScroll?: boolean;
  /** 'auto': dùng preventScroll nếu được, không thì lưu/khôi phục; 'native-only'; 'fallback-only' (để demo) */
  strategy?: 'auto' | 'native-only' | 'fallback-only';
}

export function focusElement(el: HTMLElement, { preventScroll = false, strategy = 'auto' }: FocusOptionsEx = {}) {
  const prev = programmaticFocus;
  programmaticFocus = true;
  try {
    if (!preventScroll) el.focus();
    else if (strategy !== 'fallback-only' && supportsPreventScroll()) el.focus({ preventScroll: true });
    else if (strategy === 'native-only') el.focus();
    else {
      const restore = snapshotScroll(el);
      el.focus();
      restore();
    }
  } finally {
    programmaticFocus = prev;
  }
}
ts

#Đi qua code

  • usableRows lọc ô disabled, rồi bỏ hàng rỗng. Mọi phép tính đi trên danh sách đã lọc; hàm at() đổi ngược về chỉ số gốc để gọi focus đúng phần tử.
  • startColumn cộng colSpan các ô đứng trước. Ô "Hội thảo (T2–T4)" có colSpan: 3, nên ô "T5 trống" sau nó bắt đầu ở cột 3 chứ không phải cột 1.
  • cellIndexAtColumn tìm ô phủ cột: đi xuống từ "T5 trống" (cột 3) tới hàng dưới ra đúng "T5 13". Từ "Hội thảo" (cột 0) đi xuống ra "T2 10".
  • RTL được xử lý bằng cách đổi tên phím trước switch, đúng như bản gốc. Phần hiển thị thì để CSS lo: dir="rtl" lật lưới grid.
  • supportsPreventScroll chạy lười một lần, cache kết quả. focusElement bật cờ programmaticFocus trong lúc gọi el.focus(): handler onFocus chạy đồng bộ bên trong lời gọi focus, nên đọc cờ lúc đó là chính xác.

Khác bản gốc:

  • Bản gốc dựa vào React.unstable_Scope (API thử nghiệm, không có trong bản React phát hành). Bản viết lại nhận mảng phần tử, framework nào cũng dùng được.
  • Nhánh End (không Ctrl) của bản gốc so chỉ số ô với object ô cuối (j!==x[x.length-1]): một số so với một object thì luôn khác, nên đang ở ô cuối mà bấm End vẫn gọi focus lại. Vô hại nhưng thừa; bản viết lại so chỉ số.
  • Bản gốc tạm đặt tabIndex = -1 rồi gán lại a.tabIndex = d. Với <button> không có thuộc tính tabindex, phép gán này thêm thuộc tính tabindex="0" vào DOM. Không sai, nhưng là tác dụng phụ; bản viết lại để roving tabindex lo phần tabIndex.

#Tại sao không làm cách khác?

Để mỗi ô tabIndex=0? Người dùng bàn phím phải Tab qua 42 ô của lịch mới tới nút "Lưu". WAI-ARIA khuyên lưới (role="grid") chỉ chiếm một điểm dừng Tab, điều hướng bên trong bằng mũi tên.

Dùng aria-activedescendant thay roving tabindex? Đó là cách thứ hai trong hướng dẫn ARIA: focus nằm yên trên container, chỉ đổi thuộc tính trỏ tới ô "đang chọn". Hợp với combobox/listbox có ô nhập. Với lưới mà mỗi ô là nút bấm thật, roving tabindex đơn giản hơn: focus nằm đúng trên nút, trình đọc màn hình đọc đúng nút, :focus-visible hoạt động bình thường.

Tính cột bằng getBoundingClientRect (đo vị trí x)? Chạy được, nhưng mỗi phím bấm là một lần đọc layout, và gặp ô nửa cột, RTL, cuộn ngang thì phải xử lý thêm. Cộng colSpan là phép tính thuần, ra đúng "cột logic" mà người dùng hiểu.

scrollIntoView({ block: 'nearest' }) sau khi focus? Giải quyết chiều ngược lại (muốn cuộn tới). Ở đây ta muốn focus mà không cuộn, ví dụ trả focus về nút ở đầu một danh sách dài mà người dùng đang đọc dở ở giữa.

#Edge case và cạm bẫy

  • Hàng ngắn hơn cột hiện tại. Đi xuống từ cột 6 tới hàng "T2 31 | Nghỉ lễ (T3–CN)" ra ô "Nghỉ lễ" vì nó phủ cột 1–6. Nếu hàng thật sự ngắn (thiếu cột), về ô cuối, giống bản gốc.
  • Ô disabled giữa hàng. Bản gốc (và bản viết lại) tính cột trên danh sách đã lọc disabled. Hệ quả: ô disabled không chiếm cột trong phép tính, nên đi xuống qua hàng có ô disabled ở giữa có thể lệch một cột so với mắt nhìn. Cần chính xác tuyệt đối thì giữ ô disabled trong phép tính cột, chỉ bỏ qua nó khi chọn ô đích.
  • IME và phím ký tự. Bản gốc gửi phím in được (PRINT_CHAR) qua onNavigate để component ngoài làm "gõ để nhảy" (type-ahead). Đừng chặn phím ký tự khi focus đang ở ô nhập liệu bên trong lưới.
  • preventScroll không chặn cuộn do bàn phím ảo hay do scroll-snap. Nó chỉ chặn bước "cuộn tới phần tử được focus" của chính lời gọi focus.
  • Fallback lưu/khôi phục có thể chớp một frame trên máy yếu nếu trình duyệt kịp vẽ giữa focus và gán lại. Trong thực tế cả hai chạy đồng bộ trong cùng một task nên trình duyệt không vẽ ở giữa.
  • Cờ "focus do code" phải đọc đồng bộ. Nếu handler onFocus của bạn await hay setTimeout rồi mới đọc cờ, cờ đã tắt.

#Dùng trong dự án

const grid = () => rows.map((row, r) =>
  row.map((c, i) => ({ el: refs[r][i]!, colSpan: c.span, disabled: c.disabled })));

function onKeyDown(e: React.KeyboardEvent) {
  const key = e.key as GridKey;
  if (!['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Home','End','PageUp','PageDown'].includes(key)) return;
  e.preventDefault(); // đừng để trang cuộn theo mũi tên
  const pos = nextPosition(grid(), active, key, { ctrl: e.ctrlKey || e.metaKey }, { rtl: dir === 'rtl' });
  if (pos) {
    setActive(pos);
    focusElement(refs[pos.row][pos.index]!);
  }
}
useEffect(() => applyRovingTabIndex(grid(), active), [active]);

// Đóng dialog: trả focus về nút mở mà không cuộn trang
focusElement(openerRef.current!, { preventScroll: true });
tsx

Nhớ đặt role="grid", role="row", role="gridcell" (hoặc dùng <table>) để trình đọc màn hình biết đây là lưới.

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { nextPosition, findCellByTag, type GridCell } from './focusTable';

const cell = (name: string, extra: Partial<GridCell> = {}) => ({ el: { name } as unknown as HTMLElement, ...extra });
const grid: GridCell[][] = [
  [cell('a0'), cell('a1'), cell('a2'), cell('a3')],
  [cell('b0', { colSpan: 3 }), cell('b3', { tag: 'x' })],
  [cell('c0', { disabled: true }), cell('c1', { disabled: true })],
  [cell('d0'), cell('d1'), cell('d2'), cell('d3')],
];
const name = (p: { row: number; index: number } | null) => p && (grid[p.row][p.index].el as any).name;

describe('nextPosition', () => {
  it('đi xuống giữ cột thật qua ô gộp cột', () => {
    expect(name(nextPosition(grid, { row: 0, index: 2 }, 'ArrowDown'))).toBe('b0');
    expect(name(nextPosition(grid, { row: 3, index: 2 }, 'ArrowUp'))).toBe('b0');
  });
  it('bỏ qua hàng toàn ô disabled', () => {
    expect(name(nextPosition(grid, { row: 1, index: 0 }, 'ArrowDown'))).toBe('d0');
  });
  it('RTL đổi mũi tên trái/phải', () => {
    expect(name(nextPosition(grid, { row: 0, index: 1 }, 'ArrowLeft', {}, { rtl: true }))).toBe('a2');
  });
  it('End ở ô cuối thì đứng yên, Ctrl+End về cuối bảng', () => {
    expect(nextPosition(grid, { row: 0, index: 3 }, 'End')).toBeNull();
    expect(name(nextPosition(grid, { row: 0, index: 0 }, 'End', { ctrl: true }))).toBe('d3');
  });
  it('tìm ô theo tag', () => {
    expect(name(findCellByTag(grid, { row: 0, index: 0 }, 'x'))).toBe('b3');
  });
});
ts

#So với thư viện và chuẩn web

  • React Aria (useGridList, useTable) và Reach UI có điều hướng lưới đầy đủ kèm ARIA. Nếu đã dùng một thư viện component, dùng sẵn của nó.
  • focus({ preventScroll: true }) có trong mọi trình duyệt chính hiện nay (tính tới 10/2026, xem MDN); đoạn fallback chỉ cần nếu bạn còn hỗ trợ trình duyệt rất cũ. Mẹo phát hiện bằng getter thì vẫn đáng biết cho mọi API nhận "options object".
  • Thuộc tính HTML focusgroup (điều hướng bằng mũi tên do trình duyệt lo) mới ở dạng đề xuất/thử nghiệm, chưa dùng được rộng rãi (tính tới 10/2026).

Bài liên quan: Học từ Facebook #8: focus trap và Esc theo layer.


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

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz