Bài 27.2: Keyboard navigation trong TypeScript

Bài 27.2: Keyboard navigation trong TypeScript
Chào mừng trở lại với chuỗi bài viết về Lập trình Web Front-end! Hôm nay, chúng ta sẽ đi sâu vào một khía cạnh quan trọng thường bị bỏ qua nhưng lại mang lại lợi ích to lớn cho người dùng: Điều hướng bằng bàn phím (Keyboard Navigation) trong ứng dụng web, và cách triển khai nó một cách mạnh mẽ và an toàn bằng TypeScript.
Tại sao Điều hướng bằng Bàn phím lại Quan trọng?
Trong thế giới web hiện đại, chúng ta thường tập trung vào giao diện người dùng trực quan và tương tác bằng chuột hoặc cảm ứng. Tuy nhiên, điều hướng bằng bàn phím là nền tảng của trải nghiệm web cho nhiều đối tượng người dùng, bao gồm:
- Người dùng Khuyết tật: Những người không thể sử dụng chuột do khuyết tật vận động hoặc thị giác phụ thuộc hoàn toàn vào bàn phím và các công nghệ hỗ trợ (như trình đọc màn hình) để tương tác với nội dung. Một trang web không thể điều hướng bằng bàn phím là không thể truy cập đối với họ.
- Người dùng Năng suất (Power Users): Nhiều người dùng chuyên nghiệp thích sử dụng bàn phím để thao tác nhanh chóng và hiệu quả hơn là di chuyển chuột. Các phím tắt và khả năng điều hướng mượt mà giúp họ tiết kiệm thời gian đáng kể.
- Người dùng với Thiết bị Khác: Đôi khi, người dùng có thể truy cập trang web của bạn trên các thiết bị hoặc môi trường mà việc sử dụng chuột gặp khó khăn hoặc không khả dụng.
Hiểu và triển khai đúng Keyboard Navigation không chỉ là tuân thủ các tiêu chuẩn về khả năng tiếp cận (Accessibility - A11y) mà còn là nâng cao trải nghiệm người dùng cho tất cả mọi người.
Hành Vi Mặc Định Của Trình Duyệt
Các trình duyệt hiện đại cung cấp một mức độ điều hướng bằng bàn phím cơ bản ngay từ đầu:
- Phím
Tab
: Di chuyển tiêu điểm (focus) giữa các phần tử tương tác (liên kết, nút, trường form, v.v.) theo thứ tự xuất hiện trong mã HTML. - Phím
Shift + Tab
: Di chuyển tiêu điểm theo thứ tự ngược lại. - Phím
Enter
: Kích hoạt (click) phần tử đang có tiêu điểm (thường là nút hoặc liên kết). - Phím
Space
: Kích hoạt (click) phần tử đang có tiêu điểm (thường là nút hoặc checkbox), hoặc cuộn trang nếu không có phần tử tương tác nào được focus. - Phím mũi tên: Cuộn trang, hoặc điều hướng trong các widget phức tạp hơn (ví dụ: thanh trượt, menu).
Khi một phần tử nhận tiêu điểm, trình duyệt sẽ hiển thị một đường viền hoặc hiệu ứng trực quan xung quanh nó – đây là chỉ báo tiêu điểm (focus indicator) và nó cực kỳ quan trọng. Đừng bao giờ ẩn nó bằng CSS (outline: none;
) trừ khi bạn cung cấp một chỉ báo thay thế rõ ràng hơn.
Tại Sao Cần Tùy Chỉnh Hành Vi Mặc Định?
Hành vi mặc định của trình duyệt là tốt cho các trang web đơn giản. Tuy nhiên, đối với các ứng dụng web phức tạp với các thành phần UI tùy chỉnh (như modal, carousel, grid, menu dropdown phức tạp, trình chỉnh sửa văn bản phong phú), chúng ta cần can thiệp để đảm bảo điều hướng bằng bàn phím vẫn logic, hiệu quả và có thể truy cập.
Ví dụ:
- Trong một Modal Dialog, khi modal mở, tiêu điểm cần được đặt vào bên trong modal và bị "khóa" lại (người dùng nhấn Tab chỉ di chuyển giữa các phần tử trong modal, không phải các phần tử phía sau). Khi modal đóng, tiêu điểm cần được đưa trở lại phần tử đã mở modal.
- Trong một Carousel hoặc Tab Interface, người dùng có thể muốn sử dụng phím mũi tên trái/phải để chuyển đổi giữa các slide hoặc các tab thay vì chỉ dùng Tab để di chuyển qua tất cả các nút điều khiển.
- Trong một Data Grid tùy chỉnh, người dùng có thể muốn dùng các phím mũi tên để di chuyển giữa các ô.
Đây là lúc chúng ta cần sử dụng JavaScript, và cụ thể hơn là TypeScript để quản lý các sự kiện bàn phím.
Xử Lý Sự Kiện Bàn Phím Với TypeScript
TypeScript mang lại lợi ích của việc kiểm tra kiểu tĩnh cho các sự kiện DOM, giúp mã của chúng ta an toàn và dễ hiểu hơn. Các sự kiện bàn phím chính mà chúng ta quan tâm là:
keydown
: Xảy ra khi một phím được nhấn xuống.keypress
: Xảy ra khi một phím tạo ra ký tự được nhấn và giữ. (Lưu ý: Sự kiện này đã bị phản đối (deprecated) và không nên dùng cho các logic mới; hãy dùngkeydown
hoặckeyup
thay thế).keyup
: Xảy ra khi một phím được nhả ra.
Trong hầu hết các trường hợp điều hướng tùy chỉnh, keydown
là sự kiện được sử dụng phổ biến nhất vì nó kích hoạt ngay lập tức khi phím được nhấn và cho phép chúng ta ngăn chặn hành vi mặc định của trình duyệt trước khi nó xảy ra.
Đối tượng sự kiện cho các sự kiện này là KeyboardEvent
. TypeScript giúp chúng ta truy cập các thuộc tính của nó một cách an toàn:
document.addEventListener('keydown', (event: KeyboardEvent) => {
// event là một đối tượng KeyboardEvent
console.log('Phím đã nhấn:', event.key);
console.log('Mã phím:', event.code);
console.log('Ctrl được nhấn?', event.ctrlKey);
console.log('Shift được nhấn?', event.shiftKey);
console.log('Alt được nhấn?', event.altKey);
console.log('Meta (Cmd/Windows) được nhấn?', event.metaKey);
});
event.key
: Chuỗi biểu thị giá trị của phím đã nhấn (ví dụ:'Enter'
,'Escape'
,'ArrowUp'
,'a'
,'F5'
). Đây là thuộc tính nên dùng nhất cho hầu hết các trường hợp.event.code
: Chuỗi biểu thị mã vật lý của phím (ví dụ:'Enter'
,'Escape'
,'ArrowUp'
,'KeyA'
,'F5'
). Hữu ích khi bạn cần biết vị trí phím trên bàn phím độc lập với layout ngôn ngữ.event.keyCode
: Số biểu thị mã phím cũ. Không nên dùng trong mã mới vì nó đã bị phản đối và không đáng tin cậy trên mọi trình duyệt/layout.
Ngăn Chặn Hành Vi Mặc Định:
Một công cụ mạnh mẽ và cần thiết khi tùy chỉnh điều hướng bàn phím là event.preventDefault()
. Gọi phương thức này sẽ ngăn chặn trình duyệt thực hiện hành vi mặc định liên quan đến sự kiện đó.
Ví dụ: Ngăn Space làm cuộn trang khi nhấn trên một phần tử không phải nút/checkbox:
const myDiv = document.getElementById('myCustomElement');
if (myDiv) {
myDiv.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.code === 'Space') { // Hoặc event.key === ' '
event.preventDefault(); // Ngăn cuộn trang mặc định
console.log('Spacebar đã nhấn trên element tùy chỉnh!');
// Thực hiện hành động tùy chỉnh của bạn ở đây
}
});
}
Ví Dụ Minh Họa: Xử Lý Phím Enter và Esc
Hãy xem một ví dụ đơn giản hơn: một trường nhập liệu và một nút. Khi người dùng nhập xong và nhấn Enter
trong trường nhập, thay vì submit form (nếu có), chúng ta muốn kích hoạt nút. Hoặc khi nhấn Esc
, chúng ta muốn xóa nội dung trường nhập.
<!-- Trong file HTML -->
<input type="text" id="myTextInput" placeholder="Nhập gì đó...">
<button id="myActionButton">Thực hiện</button>
// Trong file TypeScript
const textInput = document.getElementById('myTextInput') as HTMLInputElement | null;
const actionButton = document.getElementById('myActionButton') as HTMLButtonElement | null;
if (textInput && actionButton) {
// Lắng nghe sự kiện keydown trên trường nhập liệu
textInput.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault(); // Ngăn hành vi mặc định (ví dụ: submit form)
actionButton.click(); // Kích hoạt hành động của nút
console.log('Enter được nhấn, nút đã được kích hoạt.');
} else if (event.key === 'Escape') {
event.preventDefault(); // Ngăn hành vi mặc định (ví dụ: đóng modal nếu input nằm trong modal)
textInput.value = ''; // Xóa nội dung trường nhập
console.log('Escape được nhấn, nội dung đã bị xóa.');
}
});
// Lắng nghe sự kiện click trên nút (để kiểm tra)
actionButton.addEventListener('click', () => {
console.log('Nút "Thực hiện" đã được click.');
alert('Hành động đã được thực hiện!');
});
} else {
console.error('Không tìm thấy các phần tử HTML cần thiết!');
}
Giải thích:
- Chúng ta lấy tham chiếu đến các phần tử HTML bằng
getElementById
. Sử dụngas HTMLInputElement | null
vàas HTMLButtonElement | null
để TypeScript biết kiểu của các phần tử và có thể lànull
nếu không tìm thấy. - Kiểm tra sự tồn tại của các phần tử trước khi thêm listener.
- Thêm listener cho sự kiện
keydown
vàotextInput
. - Bên trong listener, chúng ta kiểm tra
event.key
. - Nếu là
Enter
, chúng ta gọievent.preventDefault()
để chặn mọi hành vi mặc định của trình duyệt trên trường nhập (quan trọng nếu nó nằm trong<form>
). Sau đó, chúng ta mô phỏng một click trênactionButton
bằngactionButton.click()
. - Nếu là
Escape
, chúng ta cũng gọievent.preventDefault()
(có thể hữu ích trong các kịch bản phức tạp hơn) và sau đó xóa giá trị của trường nhập. - Việc sử dụng
event: KeyboardEvent
giúp TypeScript cung cấp gợi ý tự động và kiểm tra kiểu cho các thuộc tính nhưkey
.
Ví Dụ Minh Họa: Điều hướng Bằng Phím Mũi Tên Trong Danh Sách
Đây là một kịch bản phổ biến hơn: Bạn có một danh sách các mục tùy chỉnh (không phải liên kết hoặc nút mặc định) và muốn người dùng dùng phím mũi tên Lên/Xuống để di chuyển tiêu điểm giữa các mục, và Enter/Space để chọn mục đó.
<!-- Trong file HTML -->
<ul id="myCustomList" role="listbox"> <!-- Sử dụng role="listbox" cho ngữ nghĩa ARIA -->
<li class="list-item" tabindex="-1" role="option">Mục 1</li>
<li class="list-item" tabindex="-1" role="option">Mục 2</li>
<li class="list-item" tabindex="-1" role="option">Mục 3</li>
<li class="list-item" tabindex="-1" role="option">Mục 4</li>
</ul>
<p>Sử dụng phím mũi tên Lên/Xuống để điều hướng, Enter/Space để chọn.</p>
/* Một chút CSS để hiển thị tiêu điểm */
.list-item:focus {
outline: 2px solid blue;
background-color: lightblue;
}
// Trong file TypeScript
const customList = document.getElementById('myCustomList') as HTMLUListElement | null;
if (customList) {
// Lấy tất cả các mục trong danh sách
const listItems = Array.from(customList.querySelectorAll('.list-item')) as HTMLElement[];
let focusedIndex: number = -1; // Theo dõi chỉ mục của mục đang được focus
// Ban đầu, đặt tabindex cho tất cả các mục là -1
// Chúng ta sẽ quản lý tiêu điểm bằng JS/TS
listItems.forEach(item => {
item.setAttribute('tabindex', '-1');
// Đảm bảo rằng khi click vào item, nó cũng nhận focus
item.addEventListener('click', () => {
// Đảm bảo chỉ có item này có tabindex="0" và focus
listItems.forEach(i => i.setAttribute('tabindex', '-1'));
item.setAttribute('tabindex', '0');
item.focus();
focusedIndex = listItems.indexOf(item); // Cập nhật index khi click
console.log(`Mục "${item.textContent}" được click.`);
// Thực hiện hành động chọn mục
});
});
// Lắng nghe sự kiện keydown trên toàn bộ danh sách hoặc một container chung
customList.addEventListener('keydown', (event: KeyboardEvent) => {
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
event.preventDefault(); // Ngăn chặn cuộn trang hoặc hành vi mặc định khác
if (listItems.length === 0) {
return; // Không có mục nào để điều hướng
}
if (event.key === 'ArrowDown') {
// Tính toán chỉ mục mới (quay vòng)
focusedIndex = (focusedIndex + 1) % listItems.length;
} else if (event.key === 'ArrowUp') {
// Tính toán chỉ mục mới (quay vòng ngược)
focusedIndex = (focusedIndex - 1 + listItems.length) % listItems.length;
}
// Loại bỏ tabindex="0" khỏi mục trước đó (nếu có)
listItems.forEach(item => item.setAttribute('tabindex', '-1'));
// Đặt tabindex="0" cho mục mới được focus và thực hiện focus
const nextFocusedItem = listItems[focusedIndex];
nextFocusedItem.setAttribute('tabindex', '0'); // Chỉ mục được focus mới nhận tabindex="0"
nextFocusedItem.focus(); // Đặt tiêu điểm vào mục này
console.log(`Tiêu điểm di chuyển đến Mục ${focusedIndex + 1}`);
// Xử lý Enter hoặc Space để kích hoạt mục
if (event.key === 'Enter' || event.key === ' ') {
console.log(`Mục "${nextFocusedItem.textContent}" được chọn.`);
// TODO: Thêm logic xử lý khi mục được chọn (ví dụ: gọi hàm, chuyển hướng)
alert(`Bạn đã chọn: ${nextFocusedItem.textContent}`);
}
}
});
// Thêm sự kiện focus ban đầu hoặc khi danh sách nhận focus
// Điều này giúp người dùng Tab đến danh sách và bắt đầu điều hướng bằng mũi tên
customList.addEventListener('focus', () => {
if (focusedIndex === -1 && listItems.length > 0) {
// Nếu chưa có mục nào được focus, focus mục đầu tiên
focusedIndex = 0;
listItems.forEach(item => item.setAttribute('tabindex', '-1'));
listItems[focusedIndex].setAttribute('tabindex', '0');
// Không gọi .focus() ở đây để tránh nhảy tiêu điểm đột ngột,
// hành vi focus sẽ được xử lý bởi trình duyệt khi Tab vào container có tabindex="0"
// Hoặc bạn có thể thêm một nút "Bắt đầu điều hướng danh sách" có tabindex="0"
// và khi nút đó được kích hoạt (click/enter/space), bạn set focus vào mục đầu tiên.
// Đối với ví dụ đơn giản này, chúng ta giả định container cha nhận focus hoặc click
// và sau đó mũi tên mới hoạt động.
// Một cách tiếp cận phổ biến hơn là cho item đầu tiên tabindex="0" và các item khác "-1".
listItems[focusedIndex].focus(); // Ép focus vào mục đầu tiên khi container được focus
}
});
// Đặt tabindex="0" cho container để nó có thể nhận focus khi Tab
customList.setAttribute('tabindex', '0');
// HOẶC đặt tabindex="0" cho item đầu tiên và tabindex="-1" cho container
// listItems[0]?.setAttribute('tabindex', '0'); // Cần kiểm tra item đầu tiên tồn tại
} else {
console.error('Không tìm thấy danh sách tùy chỉnh #myCustomList!');
}
Giải thích:
- HTML & CSS: Các mục danh sách (
<li>
) được gántabindex="-1"
ban đầu. Điều này có nghĩa là chúng có thể nhận tiêu điểm thông qua JavaScript (element.focus()
) nhưng không nằm trong luồng tab mặc định của trình duyệt. Chúng ta thêmrole="listbox"
vàrole="option"
để cải thiện ngữ nghĩa ARIA cho trình đọc màn hình. CSS đơn giản để hiển thị chỉ báo tiêu điểm. Container<ul>
được đặttabindex="0"
để người dùng có thể dùng phím Tab để nhảy vào toàn bộ danh sách. - TypeScript:
- Lấy tất cả các mục
.list-item
. - Sử dụng biến
focusedIndex
để theo dõi mục nào đang được "giả định" focus (chúng ta quản lý focus bằng tay). - Thêm listener
keydown
vào container cha (customList
). Điều này cho phép chúng ta xử lý phím bấm bất kể mục nào trong danh sách đang có tiêu điểm thực sự (hoặc container đang có tiêu điểm). - Trong listener, chúng ta kiểm tra các phím mũi tên và Space/Enter.
event.preventDefault()
là bắt buộc để ngăn cuộn trang khi nhấn mũi tên. - Tính toán
focusedIndex
mới dựa trên phím nhấn, sử dụng toán tử modulo%
để tạo hiệu ứng "quay vòng". - Quan trọng nhất là logic quản lý
tabindex
vàfocus()
:- Trước khi đặt tiêu điểm mới, chúng ta đặt lại
tabindex="-1"
cho tất cả các mục. - Chỉ mục mới được tính toán (
listItems[focusedIndex]
) được đặttabindex="0"
và sau đó gọinextFocusedItem.focus()
. Việc đặttabindex="0"
cho mục đang focus giúp mục đó nằm trong luồng tab tiếp theo nếu người dùng nhấn Tab ra khỏi danh sách và sau đó Tab quay lại (tiêu điểm sẽ nhảy đến mục cótabindex="0"
).
- Trước khi đặt tiêu điểm mới, chúng ta đặt lại
- Logic cho Enter/Space kích hoạt hành động trên mục đang được focus.
- Chúng ta cũng thêm một listener
click
cho mỗi item để đảm bảo nếu người dùng click chuột vào item, tiêu điểm vẫn được đặt đúng. - Một listener
focus
trên containercustomList
được thêm vào để khi người dùng Tab đến danh sách, mục đầu tiên sẽ tự động nhận focus.
- Lấy tất cả các mục
Ví dụ này phức tạp hơn một chút nhưng minh họa cách bạn có thể kiểm soát hoàn toàn hành vi điều hướng bằng bàn phím cho các thành phần UI tùy chỉnh, đồng thời sử dụng TypeScript để giữ cho mã gọn gàng và dễ bảo trì.
Quản Lý Tiêu Điểm (Focus Management)
Như bạn thấy trong ví dụ trên, việc quản lý tiêu điểm là cốt lõi của điều hướng bằng bàn phím tùy chỉnh. Các phương pháp chính bao gồm:
element.focus()
: Đặt tiêu điểm trực tiếp vào một phần tử.element.blur()
: Loại bỏ tiêu điểm khỏi một phần tử.element.tabIndex
(hoặc thuộc tính HTMLtabindex
):tabindex="0"
: Phần tử có thể nhận tiêu điểm qua Tab và theo thứ tự trong DOM.tabindex="-1"
: Phần tử có thể nhận tiêu điểm quaelement.focus()
nhưng không nằm trong luồng Tab mặc định.tabindex="> 0"
: Nên tránh! Đặt thứ tự Tab tùy chỉnh. Rất khó quản lý và dễ phá vỡ luồng Tab logic theo DOM. Chỉ sử dụng 0 hoặc -1.
Khi xây dựng các thành phần phức tạp như modal, dropdown, hoặc các widget tương tác, hãy luôn suy nghĩ về luồng tiêu điểm:
- Khi thành phần mở ra, tiêu điểm đi đâu? (Thường là phần tử tương tác đầu tiên bên trong).
- Khi thành phần đóng lại, tiêu điểm quay về đâu? (Thường là phần tử đã mở nó).
- Làm thế nào để "bẫy" tiêu điểm bên trong thành phần (ví dụ: trong modal) để người dùng Tab không bị thoát ra ngoài?
Các Thực Hành Tốt
Để đảm bảo trải nghiệm điều hướng bằng bàn phím tốt nhất:
- Không bao giờ xóa chỉ báo tiêu điểm mặc định trừ khi bạn thay thế nó bằng thứ gì đó rõ ràng hơn và nhất quán trên toàn bộ trang web của bạn.
- Luôn xử lý phím
Enter
vàSpace
cho các phần tử trông giống nút hoặc các điều khiển tương tác khác, ngay cả khi đó không phải là nút<button>
hoặc liên kết<a>
gốc. - Đảm bảo luồng Tab logic tuân theo thứ tự trực quan hoặc thứ tự có ý nghĩa trên trang. Tránh sử dụng
tabindex
dương. - Sử dụng ngữ nghĩa ARIA (Accessible Rich Internet Applications) để cung cấp thông tin ngữ cảnh cho trình đọc màn hình, đặc biệt với các widget tùy chỉnh. Ví dụ:
role
,aria-expanded
,aria-controls
, v.v. - Thử nghiệm trang web của bạn chỉ bằng bàn phím. Bắt đầu từ đầu trang và nhấn Tab qua mọi thứ. Bạn có thể truy cập tất cả nội dung tương tác không? Thứ tự có hợp lý không? Bạn có biết đang focus vào đâu không?
- Sử dụng TypeScript để có kiểu dữ liệu mạnh mẽ hơn cho các sự kiện bàn phím (
KeyboardEvent
) và cấu trúc mã tốt hơn khi xử lý logic phức tạp.
Comments