Web Components · Phần 7 — Events, focus và accessibility qua Shadow DOM
Làm chủ event phases, bubbles, composed, cancelable, retargeting và focus; xây kb-task-card có semantics, accessible name và keyboard contract đúng nền tảng.
Một task card có thể nhìn hoàn hảo bằng chuột nhưng vẫn thất bại khi ghép vào board: event không ra khỏi shadow root, consumer đọc nhầm event.target, phím Space cuộn trang thay vì kích hoạt, hoặc focus “biến mất” vì CSS đã xóa outline.
Shadow DOM không tạo một event system khác. Nó thêm ranh giới vào event path và focus navigation. Muốn thiết kế component tốt, ta cần phân biệt hai chuyện:
- implementation event xảy ra bên trong, như click trên một button;
- public event mô tả ý định ở cấp component, như “task T-17 muốn chuyển sang phải”.
Public event, keyboard behavior và semantics đều là API — phải được thiết kế cùng lúc.
1. Event đi qua ba phase
Với một event được dispatch từ button trong shadow tree, đường đi khái niệm là:
window → document → ... → <kb-task-card> → #shadow-root → button
capture → target → bubble
Ba phase:
- Capturing: đi từ ancestor ngoài cùng về target. Listener phải đăng ký
{ capture: true }. - At target: chạy trên target.
- Bubbling: đi ngược từ target lên ancestor nếu
event.bubbles === true.
currentTarget là node đang chạy listener. target là target đã được điều chỉnh theo vị trí quan sát. Đừng dùng hai thuộc tính này thay nhau:
board.addEventListener('click', (event) => {
console.log(event.currentTarget); // board
console.log(event.target); // target đã retarget nếu đi qua shadow
});
2. Ba cờ độc lập: bubbles, composed, cancelable
Khi tạo CustomEvent, cả ba mặc định là false:
const event = new CustomEvent('kb-task-move', {
detail: { taskId: 'T-17', toStatus: 'done' },
bubbles: true,
composed: true,
cancelable: false,
});
| Cờ | Trả lời câu hỏi |
|---|---|
bubbles | Event có đi ngược lên ancestor trong bubble phase không? |
composed | Event có được phép vượt shadow boundary không? |
cancelable | Consumer gọi preventDefault() có hủy default action được không? |
bubbles: true không tự động vượt shadow root. composed: true cũng không tự tạo bubble phase. Hai cờ giải quyết hai trục khác nhau.
UI event như click thường đã composed; custom event thì không. Public event đi qua component lồng nhau thường cần bubbles: true, composed: true.
Chỉ đặt cancelable: true nếu component có một default action rõ ràng để hủy:
const request = new CustomEvent('kb-task-toggle-request', {
detail: { taskId: this.taskId, nextCompleted: true },
bubbles: true,
composed: true,
cancelable: true,
});
if (this.dispatchEvent(request)) {
// Chỉ commit default action khi không listener nào preventDefault().
this.completed = true;
}
dispatchEvent() trả về false khi một cancelable event đã bị hủy. Event mang hậu tố request giúp consumer hiểu nó xảy ra trước state change. Sau khi commit, card phát kb-task-toggle không cancelable để thông báo state mới.
Đừng đánh dấu mọi event là cancelable “phòng khi cần”. Một event chỉ thông báo state đã đổi thì preventDefault() không thể quay thời gian ngược lại.
3. Retargeting bảo vệ chi tiết nội bộ
Giả sử click bắt đầu ở .complete-button trong shadow root. Listener nội bộ thấy button là event.target. Listener ngoài component thường thấy <kb-task-card> — trình duyệt đã retarget event tại shadow boundary.
document.addEventListener('click', (event) => {
// Không nên kỳ vọng class nội bộ ở đây.
console.log(event.target); // <kb-task-card> trong trường hợp này
// Path đầy đủ phụ thuộc vị trí listener và open/closed root.
console.log(event.composedPath());
});
composedPath() trả các EventTarget event đã đi qua; nội dung của closed shadow tree bị ẩn với code bên ngoài. API này hữu ích cho event delegation bên trong component hoặc để debug. Consumer không nên tìm .complete-button trong path: đó vẫn là phụ thuộc vào implementation detail.
Thay vào đó, card dịch click nội bộ thành một public custom event có payload ổn định:
this.dispatchEvent(
new CustomEvent('kb-task-toggle', {
detail: { taskId: this.taskId, completed: this.completed },
bubbles: true,
composed: true,
})
);
Board không cần biết card dùng một button, menu hay gesture. Đây là encapsulation hành vi, tương đương với ::part() là encapsulation style.
4. Focus qua shadow boundary
Focus bên trong open shadow root có hai góc nhìn:
document.activeElement; // thường là <kb-task-card>
card.shadowRoot.activeElement; // button thật đang focus bên trong
Host đại diện cho subtree ở document, còn shadow root biết focus sâu hơn. Khi debug “focus đang ở đâu”, hãy kiểm tra cả hai.
Có thể tạo root bằng:
this.attachShadow({ mode: 'open', delegatesFocus: true });
Khi đó, gọi host.focus() hoặc click vùng không focusable của shadow DOM có thể chuyển focus tới phần tử focusable đầu tiên; host cũng nhận style :focus tương ứng. Nhưng “đầu tiên” không luôn là hành động quan trọng nhất. Một nút đóng đứng trước input có thể nhận focus ngoài ý muốn, và tabindex="-1" cũng có thể ảnh hưởng lựa chọn.
delegatesFocus không thay thế thiết kế thứ tự tab. Với task card, API tường minh thường dễ dự đoán hơn:
focusPrimaryAction() {
this.shadowRoot.querySelector('[data-action="toggle"]').focus();
}
5. Semantics trước ARIA
Nếu một thứ hoạt động như button, dùng <button>. Ta nhận miễn phí:
- thứ tự tab đúng;
- kích hoạt bằng Enter và Space;
- disabled semantics;
- role và accessible name từ nội dung;
- hành vi click nhất quán cho chuột, bàn phím và công nghệ hỗ trợ.
Đoạn này tạo thêm nhiều việc nhưng vẫn kém button thật:
<div role="button" tabindex="0">Hoàn thành</div>
Bạn còn phải tự xử lý keydown, ngăn Space cuộn trang, đồng bộ aria-disabled và mô phỏng activation behavior. ARIA chỉ bổ sung semantics; nó không thêm hành vi.
Accessible name trả lời “control này là gì?”. Text hiển thị thường là nguồn tên tốt nhất:
<button type="button">Đánh dấu hoàn thành</button>
Với icon-only button, dùng text ẩn hoặc aria-label; title không phải accessible name đáng tin cậy. Tránh để aria-labelledby ngoài root trỏ vào ID nội bộ vì ID reference bị giới hạn bởi tree scope.
6. ElementInternals cho semantics mặc định
Autonomous custom element không có native role riêng. attachInternals() cho component khai báo default ARIA semantics mà không cần phản chiếu thành attribute công khai:
class KbTaskCard extends HTMLElement {
#internals = this.attachInternals();
constructor() {
super();
this.#internals.role = 'article';
this.#internals.ariaLabel = 'Task chưa đặt tên';
}
}
Khi title đổi, component đồng bộ internals.ariaLabel. Đây là default semantics; author sử dụng component vẫn có thể cung cấp ARIA attribute phù hợp với ngữ cảnh. Đừng tạo xung đột bằng cách liên tục ghi đè attribute mà consumer đang quản lý.
ElementInternals còn cung cấp form association, validation và labels. Phần 8 sẽ dùng toàn bộ nhóm API đó để làm một custom form control.
7. <kb-task-card> dùng được bằng bàn phím
Ví dụ runnable sau dùng native button cho hành động chính. Enter/Space được browser chuyển thành click; code không bắt lại hai phím này nên không phát event hai lần. Alt + ArrowLeft/ArrowRight là shortcut bổ sung để chuyển card.
const TASK_STATUSES = ['todo', 'doing', 'done'];
const TASK_PRIORITIES = ['low', 'medium', 'high'];
const taskTemplate = document.createElement('template');
taskTemplate.innerHTML = `
<style>
:host { display: block; }
:host([hidden]) { display: none; }
.card {
border: 1px solid var(--kb-card-border, #cbd5e1);
border-radius: 0.625rem;
padding: 0.875rem;
background: var(--kb-card-surface, #fff);
}
h3 { margin: 0 0 0.75rem; font: 650 1rem/1.4 system-ui; }
p { margin: 0.25rem 0; }
ul { margin: 0.5rem 0; padding-inline-start: 1.25rem; }
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
button { min-block-size: 2.5rem; }
button:focus-visible {
outline: 3px solid var(--kb-focus, #2563eb);
outline-offset: 2px;
}
:host([completed]) h3 { text-decoration: line-through; }
</style>
<div class="card" part="surface">
<h3></h3>
<p data-priority></p>
<p data-assignee></p>
<ul data-labels aria-label="Nhãn"></ul>
<div class="actions">
<button type="button" data-action="toggle"></button>
<button type="button" data-action="move-left">Sang trái</button>
<button type="button" data-action="move-right">Sang phải</button>
</div>
</div>
`;
class KbTaskCard extends HTMLElement {
static observedAttributes = ['completed', 'priority', 'status'];
#internals = this.attachInternals();
#root;
#events;
#replayedPreUpgradeProperties = false;
#task = Object.freeze({
title: 'Task chưa đặt tên',
assignee: '',
labels: Object.freeze([]),
});
constructor() {
super();
this.#root = this.attachShadow({ mode: 'open' });
this.#root.append(taskTemplate.content.cloneNode(true));
this.#internals.role = 'article';
}
get taskId() {
return this.getAttribute('task-id') ?? '';
}
set taskId(value) {
const next = String(value).trim();
if (next) this.setAttribute('task-id', next);
else this.removeAttribute('task-id');
}
get status() {
const value = this.getAttribute('status');
return TASK_STATUSES.includes(value) ? value : 'todo';
}
set status(value) {
const next = String(value);
if (!TASK_STATUSES.includes(next)) {
throw new RangeError(`Unsupported status: ${next}`);
}
this.setAttribute('status', next);
}
get priority() {
const value = this.getAttribute('priority');
return TASK_PRIORITIES.includes(value) ? value : 'medium';
}
set priority(value) {
const next = String(value);
if (!TASK_PRIORITIES.includes(next)) {
throw new RangeError(`Unsupported priority: ${next}`);
}
this.setAttribute('priority', next);
}
get task() {
return this.#task;
}
set task(value) {
const title = String(value?.title ?? '').trim();
if (!title) throw new TypeError('task.title is required');
this.#task = Object.freeze({
title,
assignee: value.assignee ? String(value.assignee) : '',
labels: Object.freeze(
Array.isArray(value.labels) ? value.labels.map(String) : []
),
});
this.#sync();
}
get completed() {
return this.hasAttribute('completed');
}
set completed(value) {
this.toggleAttribute('completed', Boolean(value));
}
connectedCallback() {
if (!this.#replayedPreUpgradeProperties) {
for (const name of [
'task',
'taskId',
'priority',
'status',
'completed',
]) {
this.#upgradeProperty(name);
}
this.#replayedPreUpgradeProperties = true;
}
this.#events?.abort();
this.#events = new AbortController();
const { signal } = this.#events;
this.#root.addEventListener('click', this.#handleClick, { signal });
this.#root.addEventListener('keydown', this.#handleKeydown, { signal });
this.#sync();
}
disconnectedCallback() {
this.#events?.abort();
}
attributeChangedCallback() {
this.#sync();
}
focusPrimaryAction(options) {
this.#root.querySelector('[data-action="toggle"]').focus(options);
}
#handleClick = (event) => {
const button = event
.composedPath()
.find((node) => node instanceof HTMLButtonElement && node.dataset.action);
if (button) this.#emitAction(button.dataset.action);
};
#handleKeydown = (event) => {
if (!event.altKey || event.defaultPrevented) return;
const action = {
ArrowLeft: 'move-left',
ArrowRight: 'move-right',
}[event.key];
if (!action) return;
event.preventDefault();
this.#emitAction(action);
};
#emitAction(action) {
if (action === 'toggle') {
this.completed = !this.completed;
this.dispatchEvent(
new CustomEvent('kb-task-toggle', {
detail: Object.freeze({
taskId: this.taskId,
completed: this.completed,
}),
bubbles: true,
composed: true,
})
);
return;
}
const currentIndex = TASK_STATUSES.indexOf(this.status);
const offset = action === 'move-left' ? -1 : 1;
const toStatus = TASK_STATUSES[currentIndex + offset];
if (!toStatus) return;
this.dispatchEvent(
new CustomEvent('kb-task-move', {
detail: Object.freeze({ taskId: this.taskId, toStatus }),
bubbles: true,
composed: true,
})
);
}
#sync() {
if (!this.#root) return;
const title = this.#task.title;
const surface = this.#root.querySelector('.card');
const toggle = this.#root.querySelector('[data-action="toggle"]');
const moveLeft = this.#root.querySelector('[data-action="move-left"]');
const moveRight = this.#root.querySelector('[data-action="move-right"]');
this.#root.querySelector('h3').textContent = title;
surface.dataset.priority = this.priority;
this.#root.querySelector('[data-priority]').textContent =
`Ưu tiên: ${this.priority}`;
this.#root.querySelector('[data-assignee]').textContent = this.#task
.assignee
? `Người thực hiện: ${this.#task.assignee}`
: 'Người thực hiện: Chưa giao';
const labels = this.#root.querySelector('[data-labels]');
labels.replaceChildren(
...this.#task.labels.map((label) => {
const item = document.createElement('li');
item.textContent = label;
return item;
})
);
toggle.textContent = 'Hoàn thành';
toggle.setAttribute('aria-pressed', String(this.completed));
moveLeft.disabled = this.status === 'todo';
moveRight.disabled = this.status === 'done';
this.#internals.ariaLabel = `Task: ${title}${this.completed ? ', đã hoàn thành' : ''}`;
}
#upgradeProperty(name) {
if (!Object.prototype.hasOwnProperty.call(this, name)) return;
const value = this[name];
delete this[name];
this[name] = value;
}
}
customElements.define('kb-task-card', KbTaskCard);
Toggle giữ event kb-task-toggle từ Phần 3; move dùng kb-task-move. Board chỉ nghe contract công khai:
document
.querySelector('kb-task-board')
.addEventListener('kb-task-move', (event) => {
const { taskId, toStatus } = event.detail;
console.log({ taskId, toStatus });
});
Host đã mang role article qua internals nên surface bên trong chỉ là div, tránh lặp semantics. Focus ring dùng :focus-visible, có độ tương phản và không bị xóa. Toggle button giữ tên “Hoàn thành” ổn định; aria-pressed truyền trạng thái. Arrow chỉ là input nội bộ; event đổi nó thành toStatus để contract không phụ thuộc layout, RTL hay thứ tự cột. Card không tự sửa state board — owner xử lý intent.
8. Failure modes thường gặp
- Chỉ xử lý
pointerdown: người dùng keyboard và assistive technology bị bỏ lại. Dựa vàoclickcủa native control cho activation chung. - Bắt Enter/Space rồi cũng nghe click: native button đã tạo click từ hai phím đó; xử lý cả hai đường có thể phát action hai lần.
- Quên
composed: custom event có bubble nhưng dừng ở shadow boundary. - Consumer tìm class nội bộ qua
composedPath(): retargeting đang nhắc rằng class đó không phải API. Hãy phát semantic custom event. - Xóa outline: focus vẫn di chuyển nhưng không còn tín hiệu trực quan. Thay bằng focus indicator tốt hơn, không đặt
outline: noneđơn độc. - Biến host và mọi control con thành tab stop: tạo thêm lần dừng và thứ tự khó hiểu. Chọn một keyboard model có chủ đích.
9. Bài tập
- Cơ bản: thêm nút chuyển task về
todobằngkb-task-move; xác nhận Enter, Space và click chỉ phát đúng một event. - Mở rộng: triển khai
kb-task-toggle-requestcancelable. Board gọipreventDefault()khi cột đã khóa và card không được đổicompleted. - Thử thách: viết test ghi lại
event.target,event.currentTargetvàevent.composedPath()tại shadow root, host, column và board; giải thích từng khác biệt thay vì snapshot toàn bộ path.
Cốt lõi cần nhớ
- Capture, target và bubble mô tả phase;
composedquyết định event có vượt shadow boundary hay không. - Chỉ dùng
cancelablekhi có default action cụ thể đểpreventDefault()hủy. - Retargeting che implementation detail; public event nên được dispatch từ host với payload semantic.
- Native HTML mang theo keyboard behavior, semantics và accessible name tốt hơn một
divđược vá ARIA. - Kiểm tra cả
document.activeElementvàshadowRoot.activeElementkhi debug focus. delegatesFocuslà công cụ, không phải keyboard model.- Accessibility là một phần của public contract, không phải lớp vá sau cùng.
Phần 8 sẽ đưa các nguyên tắc này vào HTML form: <kb-priority-picker> sẽ submit như control gốc, tham gia constraint validation, nhận label, phản ứng với disabled/reset và khôi phục state qua ElementInternals.