Yugen UI

Chỉ báo focus

Khi điều khiển bằng bàn phím, phần tử đang focus luôn có viền 2px tương phản cao — còn màu của control thì giữ nguyên.

Cách hoạt động

Một quy tắc toàn cục trong index.css vẽ outline cho :focus-visible — trình duyệt chỉ bật nó khi người dùng di chuyển bằng bàn phím, không bật khi click chuột.

/* src/index.css — @layer base: áp cho MỌI phần tử nhận focus bằng bàn phím */
:focus-visible {
  outline: 2px solid var(--colorStrokeFocus2); /* đen ở light, trắng ở dark */
  outline-offset: 1px;
  border-radius: var(--borderRadiusMedium);
}
Thử ngay:bấm vào vùng trống của khung dưới rồi nhấn Tab / Shift + Tab. Bấm chuột vào cùng các control đó — viền không xuất hiện.
Link

Utility tương ứng là outline-stroke-focus (token colorStrokeFocus2). Radio vẽ vòng quanh cả nút lẫn nhãn bằng has-[button:focus-visible] để vùng chỉ báo khớp vùng bấm.

Vì sao màu của control không đổi khi focus

Fluent tách focus thành một kênh riêng: hover / pressed / selected đổi màu, còn focus chỉ thêm viền.

Trạng tháiTín hiệuLý do
Rest → Hover → PressedNền / viền đậm dần (X → XHover → XPressed)Phản hồi cho con trỏ.
Selected / CheckedBrand (nền, vạch chỉ báo, chữ semibold)Trạng thái lâu dài của dữ liệu.
Focus (bàn phím)Outline 2px đen / trắng, offset 1pxVị trí hiện tại — phải kết hợp được với mọi trạng thái trên.
DisabledMàu disabled, không opacityKhông tương tác.
  • Một tab vừa được chọn vừa đang focus vẫn đọc được cả hai: vạch brand ở đáy và viền focus bao ngoài.
  • Đen / trắng luôn đạt ≥ 3:1 với mọi nền neutral của theme, bất kể brand là Sakura, Web hay brand riêng — màu brand thì không đảm bảo điều đó.
  • Đổi màu khi focus dễ bị nhầm với hover hoặc pressed, khiến người dùng tưởng đã bấm.

Ô nhập & mục trong danh sách

Hai ngoại lệ có chủ đích, cả hai đều thay outline bằng một chỉ báo tương đương.

  • Ô nhập (Input, Textarea, Select, Dropdown, Combobox, TagPicker, SpinButton, DatePicker): thanh brand 2px chạy từ giữa ra ở đáy, theo :focus-within — hiện cả khi click, vì người dùng cần biết ô nào đang nhận chữ. Thẻ <input> bên trong đặt outline-none vì đã có chỉ báo thay thế.
  • Mục trong widget tổng hợp (item của Menu, option của Dropdown / Combobox, mục Command): focus nằm ở item đang highlight, chỉ báo bằng nền background-1-hover theo data-highlighted / data-selected.

Phần tử tự viết

Vì quy tắc nằm ở base, phần tử tương tác tự viết có vòng focus miễn phí. Việc của bạn là đừng vô tình xoá nó.

Danh sách nằm trong khung overflow-hidden: -outline-offset-2 kéo viền vào trong để không bị cắt.

{/* Phần tử tự viết tự có vòng focus nhờ base style — chỉ cần tabIndex và role đúng */}
<div
  role="button"
  tabIndex={0}
  onClick={open}
  onKeyDown={(e) => {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      open();
    }
  }}
  className="flex items-center gap-2 rounded-medium p-3 hover:bg-subtle-hover"
>
  …
</div>

{/* Trong khung overflow-hidden (list, card bo góc): kéo outline vào trong để không bị cắt */}
<a href="/files/42" className="block px-3 py-2 hover:bg-subtle-hover focus-visible:-outline-offset-2">…</a>

{/* Muốn đổi kiểu viền thì THAY bằng một chỉ báo tương đương, không xoá */}
<button className="outline-none focus-visible:ring-2 focus-visible:ring-stroke-focus focus-visible:ring-offset-1">…</button>
Nên
  • Dùng focus-visible: cho mọi style chỉ dành cho bàn phím.
  • Kéo outline vào trong (-outline-offset-2) khi phần tử nằm sát mép khung overflow-hidden.
  • Nếu thay outline, dùng chỉ báo tương đương: ≥ 2px, tương phản ≥ 3:1, bao quanh phần tử.
Không nên
  • Không dùng outline-none / focus:outline-none mà không có chỉ báo thay thế.
  • Không dùng focus: cho viền — nó hiện cả khi click chuột.
  • Không chỉ đổi màu nền hay màu brand làm tín hiệu focus duy nhất.

Quản lý focus: dialog, menu, popover

Radix lo phần khó: giữ focus trong Dialog / Drawer, đưa focus vào bề mặt khi mở và trả về trigger khi đóng.

Mở bằng Enter, đóng bằng Esc — focus quay lại nút.

function RenameDialog() {
  const nameRef = React.useRef<HTMLInputElement>(null);
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button>Đổi tên</Button>
      </DialogTrigger>
      <DialogSurface
        // Mặc định Radix focus phần tử đầu tiên (nút X). Chuyển focus vào ô cần nhập:
        onOpenAutoFocus={(e) => {
          e.preventDefault();
          nameRef.current?.select();
        }}
      >
        <DialogTitle>Đổi tên tệp</DialogTitle>
        <DialogContent>
          <Field label="Tên mới">
            <Input ref={nameRef} defaultValue="Báo cáo quý 3" />
          </Field>
        </DialogContent>
        <DialogActions>
          <DialogClose asChild>
            <Button>Huỷ</Button>
          </DialogClose>
          <DialogClose asChild>
            <Button appearance="primary">Lưu</Button>
          </DialogClose>
        </DialogActions>
      </DialogSurface>
    </Dialog>
  );
}
{/* Đóng bằng Esc, nút X hay Huỷ → focus tự quay về nút "Đổi tên" */}
Tình huốngViệc cần làm
Mở Dialog / DrawerMặc định focus phần tử focus được đầu tiên. Dùng onOpenAutoFocus để chọn ô nhập chính.
Đóng overlayFocus tự về trigger. Dùng onCloseAutoFocus nếu cần trả về nơi khác.
Trigger biến mất (xoá item vừa mở menu)Tự chuyển focus tới item kế tiếp hoặc tiêu đề danh sách — đừng để focus rơi về <body>.
Điều hướng trang trong SPAĐưa focus về tiêu đề h1 (tabIndex={-1}) hoặc vùng main của trang mới.
Nội dung tải thêmGiữ focus tại chỗ; thông báo số mục mới qua live region thay vì kéo focus đi.