Học từ Facebook #24: Bảng reaction nhấn giữ rồi trượt
Cách Facebook làm bảng cảm xúc nhấn giữ rồi trượt ngón tay để chọn: elementFromPoint, chặn click 200ms, lật bảng dưới header, roving tabindex có RTL.
Nút "Thích" của Facebook có hai cách dùng. Bấm nhanh là Thích. Nhấn giữ thì một dải cảm xúc bật lên, người dùng không nhấc tay, trượt ngón tay sang 😆 rồi thả ra để chọn. Một thao tác, không cần chạm hai lần.
Làm thao tác này cho đúng khó hơn vẻ ngoài:
- Khi ngón tay trượt, mọi sự kiện chạm vẫn báo về nút Thích (nơi bắt đầu chạm), không phải biểu tượng đang nằm dưới ngón tay.
- Dải cảm xúc bật lên ngay dưới con trỏ. Một cú click vừa đúng lúc đó sẽ chọn nhầm cảm xúc.
- Bài viết nằm sát thanh điều hướng thì dải cảm xúc phía trên bị che mất.
- Bàn phím và trình đọc màn hình cũng phải dùng được.
Tip
Tóm tắt
- Trong lúc trượt, nghe
movetrêndocumentvà hỏidocument.elementFromPoint(x, y)xem ngón tay đang ở ô nào. Tìm ô bằngclosest('[data-reaction]'), đừng chỉ thử một cấp cha như bản gốc. - Chặn click trong 200ms dải cảm xúc đang bật lên.
- Đỉnh dải cảm xúc lọt dưới vùng bị header che thì lật xuống dưới nút.
- Bàn phím: roving tabindex, mũi tên trái/phải đảo chiều khi RTL, Enter trên cảm xúc đang chọn là bỏ chọn. Ngày nay dùng
touch-action: none+ Pointer Events thay chopreventDefaulttrêntouchmove.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Facebook làm thế nào?
Thành phần là UFIReactionsMenuImpl.react và lớp hiển thị UFIReactionsDialogLayerImpl.react (file bundle cùng tên trong facebook-codebase). Bản dump này lấy ở giao diện tiếng Việt, nên chuỗi fbt trong code là "Cảm xúc" và "Đóng menu cảm xúc".
#Trượt ngón tay: elementFromPoint
Khi component cha báo isLongPressing, menu đăng ký 5 listener trên document.documentElement (đã đổi tên biến):
listen(document.documentElement, "touchmove", e => {
e.preventDefault();
const t = e.touches[0];
let el = document.elementFromPoint(t.clientX, t.clientY);
el && !el.getAttribute("data-reaction") && el.parentElement && (el = el.parentElement);
const r = el ? parseInt(el.getAttribute("data-reaction"), 10) || null : null;
if (r !== hovered) { hovered && onLeave(hovered); r && onEnter(r); hovered = r; }
});
listen(document.documentElement, "mousemove", /* giống hệt, dùng e.clientX/Y */);
listen(document.documentElement, "touchcancel", stop);
listen(document.documentElement, "touchend", e => { stop(); hovered && onReactionClick(hovered, e); });
listen(document.documentElement, "mouseup", e => { stop(); hovered && props.shown && onReactionClick(hovered, e); });
js
Vì sao phải elementFromPoint? Với Touch Events, touchmove luôn có target là phần tử nơi ngón tay bắt đầu chạm, dù ngón tay đã đi đâu. Không có sự kiện touchenter nào cho phần tử bên dưới. Cách duy nhất biết ngón tay đang ở trên ô nào là hỏi trình duyệt "phần tử nào ở toạ độ này".
Hai điểm yếu trong đoạn trên:
- Chỉ thử thêm đúng một cấp cha.
data-reactionnằm trên mộtdivbọc ngoài. Bên trong làdiv._39nchứa biểu tượng (CometReactionIcon) và nhãn. Nếu điểm chạm rơi vào phần tử nằm sâu hơn một cấp (thường là chính biểu tượng),parentElementvẫn chưa phảidivcó thuộc tính,parseInt(null)raNaN, coi như ngón tay đang ở ngoài. Có lẽ CSS của Facebook đặtpointer-events: nonecho phần bên trong để tránh chuyện này, nhưng bản dump không có CSS nên không kiểm chứng được. Bật "Dò ô kiểu bản gốc" trong demo, trượt vào giữa biểu tượng: ô không sáng lên, thả tay cũng không chọn được gì. preventDefault()trêntouchmoveở cấp document. Chrome mặc định coi listenertouchmovegắn trênwindow,document,bodylà passive, và bỏ quapreventDefault()trong đó. NếuEvent.listencủa Facebook không truyền{ passive: false }(bản dump không cho thấy rõ), trình duyệt vẫn cuộn trang khi trượt.
#Chặn click 200ms
onReactionClick(reaction, e) {
e.preventDefault();
!this.state.isAnimatingDock && this.props.onReactionClick && this.props.onReactionClick(reaction, e);
}
js
UFIReactionsDialogLayerImpl bật isAnimatingDock trong beforeShow, thêm class animation, và tắt sau setTimeout(…, 200) trong afterShow. Trên desktop, dải cảm xúc mở khi rê chuột lên nút Thích. Người dùng định bấm Thích, đúng lúc đó dải cảm xúc bật lên ngay dưới con trỏ, và cú click rơi vào một cảm xúc. 200ms đủ để bỏ qua cú click "trễ" đó.
#Lật xuống dưới khi bị header che
afterShow() {
const y = getElementPosition(contentRoot).y, top = ViewportBounds.getTop();
CSS.conditionClass(contentRoot, BELOW_CLASS, !!(y && y < top));
...
}
js
ViewportBounds.getTop() là chiều cao vùng bị thanh xanh cố định che. Đỉnh dải cảm xúc nằm trên ranh giới đó thì thêm class để đặt nó xuống dưới nút.
#Bàn phím và trình đọc màn hình
- Mỗi ô có
role="button",aria-pressedcho cảm xúc đang chọn,aria-labellà tên cảm xúc. Dải cảm xúc làrole="toolbar"(hoặcdialogở chế độ khác) với nhãn "Cảm xúc". tabIndextheo kiểu roving: chỉ ô đang chọn (hoặc cảm xúc hiện tại của người dùng) cótabIndex=0, các ô khác-1. Tab đi qua dải cảm xúc chỉ dừng một lần.RTLKeys.getLeft()/getRight()đảo phím mũi tên khi giao diện viết từ phải sang trái.- Enter/Space trên đúng cảm xúc đang chọn thì gửi
UFIReactionTypes.NONE: bỏ cảm xúc. - Tab ra khỏi dải cảm xúc: layer tự tìm phần tử tabbable kế tiếp sau nút bằng
compareDocumentPosition(el) & 4(DOCUMENT_POSITION_FOLLOWING), vì dải cảm xúc nằm trong một layer khác chỗ trong DOM, Tab tự nhiên sẽ nhảy lung tung. - Khi ẩn, menu không bị gỡ khỏi DOM mà mang class
accessible_elem(ẩn khỏi màn hình nhưng trình đọc màn hình vẫn đọc được).
#Bên trong trình duyệt: Touch Events, Pointer Events và capture
sequenceDiagram
participant F as "Ngón tay"
participant B as "Trình duyệt"
participant J as "JavaScript"
F->>B: "chạm vào nút Thích"
B->>J: "pointerdown, target = nút"
Note over J: "giữ 400ms: mở dải cảm xúc"
F->>B: "trượt sang 😆"
B->>J: "pointermove, target vẫn = nút (implicit capture)"
J->>B: "elementFromPoint(x, y)"
B-->>J: "span bên trong ô Haha"
Note over J: "closest('[data-reaction]') ra 'haha'"
F->>B: "nhấc tay"
B->>J: "pointerup: chọn haha"
Với Pointer Events, trình duyệt "capture" ngầm pointer cảm ứng vào phần tử nhận pointerdown. Hệ quả giống Touch Events: mọi pointermove sau đó có target là nút. Có thể gọi releasePointerCapture để các sự kiện sau mang target đúng phần tử dưới ngón tay, nhưng elementFromPoint vẫn là cách chắc chắn nhất và chạy giống nhau với chuột lẫn cảm ứng.
Muốn trình duyệt không cuộn trang khi ngón tay trượt từ nút, cách hiện đại là CSS touch-action: none trên nút. Trình duyệt biết ngay từ lúc chạm là không được cuộn, không cần JavaScript preventDefault(), nên không đụng tới chuyện listener passive.
#Viết lại
Bản viết lại là một hook usePressSlide. Tìm ô từ toạ độ, hỗ trợ cả hai chế độ để so sánh:
src/lib/reactionDock.ts
const ATTR = 'data-reaction';
function itemAt<T extends string>(x: number, y: number, mode: 'closest' | 'parent', items: readonly T[]): T | null {
let el = document.elementFromPoint(x, y) as HTMLElement | null;
if (!el) return null;
if (mode === 'closest') {
el = el.closest<HTMLElement>(`[${ATTR}]`);
} else if (!el.hasAttribute(ATTR) && el.parentElement) {
el = el.parentElement; // bản gốc: thử thêm đúng một cấp
}
const value = el?.getAttribute(ATTR) as T | null | undefined;
return value && items.includes(value) ? value : null;
}
ts
Trong lúc trượt:
src/lib/reactionDock.ts
// trong lúc trượt: nghe trên document, hỏi elementFromPoint xem đang ở ô nào
useEffect(() => {
if (!open) return;
const onMove = (e: globalThis.PointerEvent) => {
if (!sliding.current) return;
setHovered(itemAt(e.clientX, e.clientY, hitTest, items));
};
const onUp = (e: globalThis.PointerEvent) => {
if (!sliding.current) return;
sliding.current = false;
const item = itemAt(e.clientX, e.clientY, hitTest, items);
if (item) {
onSelectRef.current(item, 'release');
close();
} else {
setHovered(null); // thả tay ở ngoài: dock vẫn mở để chạm chọn
}
};
const onCancel = () => {
sliding.current = false;
setHovered(null);
};
const onOutside = (e: globalThis.PointerEvent) => {
const t = e.target as Node;
if (!dockRef.current?.contains(t) && !triggerRef.current?.contains(t)) close();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onCancel);
document.addEventListener('pointerdown', onOutside);
return () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onCancel);
document.removeEventListener('pointerdown', onOutside);
};
}, [open, hitTest, items, close, dockRef, triggerRef]);
ts
Props cho từng ô (chặn click khi đang bật lên, Enter bỏ chọn):
src/lib/reactionDock.ts
const itemProps = (item: T, index: number) => ({
[ATTR]: item,
'aria-pressed': current === item,
tabIndex: index === (focusIndex < 0 ? Math.max(0, current ? items.indexOf(current) : 0) : focusIndex) ? 0 : -1,
onClick(e: { detail: number }) {
if (blockClicksWhileAnimating && animatingRef.current) return; // dock vừa mọc dưới con trỏ
// Enter/Space trên reaction đang chọn = bỏ chọn (FB: NONE)
const how = e.detail === 0 ? 'keyboard' : 'click';
onSelectRef.current(how === 'keyboard' && current === item ? null : item, how);
close(how === 'keyboard');
},
onFocus() {
setFocusIndex(index);
},
});
ts
Xem toàn bộ src/lib/reactionDock.ts
src/lib/reactionDock.ts
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent, type RefObject } from 'react';
/**
* Bảng reaction "nhấn giữ rồi trượt" (ý tưởng từ UFIReactionsMenuImpl / UFIReactionsDialogLayerImpl của Facebook).
*
* - Khi kéo, sự kiện chạm (touch) và pointer có capture luôn bắn về phần tử bắt đầu chạm,
* nên phải tự hỏi "ngón tay đang ở trên ô nào" bằng document.elementFromPoint.
* - Dock vừa bật lên (animation 200ms) thì chặn click: dock mọc ngay dưới con trỏ, không được chọn nhầm.
* - Dock không đủ chỗ phía trên (bị header che) thì lật xuống dưới nút.
* - Bàn phím: roving tabindex, mũi tên trái/phải đảo theo RTL, Enter trên reaction đang chọn = bỏ chọn.
*/
export interface PressSlideOptions<T extends string> {
items: readonly T[];
/** reaction người dùng đang chọn (null = chưa thả cảm xúc) */
current: T | null;
onSelect: (item: T | null, how: 'release' | 'click' | 'keyboard' | 'quick') => void;
/** giữ bao lâu thì mở dock (ms) */
longPressMs?: number;
/** thời gian animation mở dock; click trong khoảng này bị bỏ qua (FB: 200) */
dockAnimationMs?: number;
blockClicksWhileAnimating?: boolean;
/**
* Cách tìm ô reaction từ phần tử dưới ngón tay:
* 'closest' (bản viết lại) hoặc 'parent' (bản gốc: chỉ thử thêm đúng 1 cấp cha)
*/
hitTest?: 'closest' | 'parent';
/** chiều cao vùng bị che ở đỉnh viewport (header cố định) */
topInset?: number;
/** chuột (không phải cảm ứng) dừng trên nút bấy nhiêu ms thì mở dock; null = tắt */
openOnHoverMs?: number | null;
dockRef: RefObject<HTMLElement | null>;
triggerRef: RefObject<HTMLElement | null>;
}
const ATTR = 'data-reaction';
function itemAt<T extends string>(x: number, y: number, mode: 'closest' | 'parent', items: readonly T[]): T | null {
let el = document.elementFromPoint(x, y) as HTMLElement | null;
if (!el) return null;
if (mode === 'closest') {
el = el.closest<HTMLElement>(`[${ATTR}]`);
} else if (!el.hasAttribute(ATTR) && el.parentElement) {
el = el.parentElement; // bản gốc: thử thêm đúng một cấp
}
const value = el?.getAttribute(ATTR) as T | null | undefined;
return value && items.includes(value) ? value : null;
}
export function usePressSlide<T extends string>(options: PressSlideOptions<T>) {
const {
items,
current,
longPressMs = 400,
dockAnimationMs = 200,
blockClicksWhileAnimating = true,
hitTest = 'closest',
topInset = 0,
openOnHoverMs = null,
dockRef,
triggerRef,
} = options;
const onSelectRef = useRef(options.onSelect);
onSelectRef.current = options.onSelect;
const [open, setOpen] = useState(false);
const [animating, setAnimating] = useState(false);
const [placement, setPlacement] = useState<'above' | 'below'>('above');
const [hovered, setHovered] = useState<T | null>(null);
const [focusIndex, setFocusIndex] = useState(-1);
const sliding = useRef(false);
const pressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const openedByPress = useRef(false);
const animatingRef = useRef(false);
const close = useCallback((restoreFocus = false) => {
setOpen(false);
setHovered(null);
setFocusIndex(-1);
sliding.current = false;
if (restoreFocus) triggerRef.current?.focus();
}, [triggerRef]);
const openDock = useCallback(
(focusFirst: boolean) => {
const trigger = triggerRef.current;
// đủ chỗ phía trên (sau khi trừ header) thì đặt trên, không thì lật xuống dưới
const dockHeight = 64;
if (trigger) setPlacement(trigger.getBoundingClientRect().top - dockHeight - 8 < topInset ? 'below' : 'above');
setOpen(true);
animatingRef.current = true;
setAnimating(true);
setTimeout(() => {
animatingRef.current = false;
setAnimating(false);
}, dockAnimationMs);
const start = Math.max(0, current ? items.indexOf(current) : 0);
setFocusIndex(focusFirst ? start : -1);
},
[current, dockAnimationMs, items, topInset, triggerRef],
);
// trong lúc trượt: nghe trên document, hỏi elementFromPoint xem đang ở ô nào
useEffect(() => {
if (!open) return;
const onMove = (e: globalThis.PointerEvent) => {
if (!sliding.current) return;
setHovered(itemAt(e.clientX, e.clientY, hitTest, items));
};
const onUp = (e: globalThis.PointerEvent) => {
if (!sliding.current) return;
sliding.current = false;
const item = itemAt(e.clientX, e.clientY, hitTest, items);
if (item) {
onSelectRef.current(item, 'release');
close();
} else {
setHovered(null); // thả tay ở ngoài: dock vẫn mở để chạm chọn
}
};
const onCancel = () => {
sliding.current = false;
setHovered(null);
};
const onOutside = (e: globalThis.PointerEvent) => {
const t = e.target as Node;
if (!dockRef.current?.contains(t) && !triggerRef.current?.contains(t)) close();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onCancel);
document.addEventListener('pointerdown', onOutside);
return () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onCancel);
document.removeEventListener('pointerdown', onOutside);
};
}, [open, hitTest, items, close, dockRef, triggerRef]);
// roving tabindex: đưa focus tới ô đang chọn
useLayoutEffect(() => {
if (!open || focusIndex < 0) return;
dockRef.current?.querySelectorAll<HTMLElement>(`[${ATTR}]`)[focusIndex]?.focus();
}, [open, focusIndex, dockRef]);
useEffect(() => () => {
if (pressTimer.current) clearTimeout(pressTimer.current);
}, []);
const cancelPress = () => {
if (pressTimer.current) clearTimeout(pressTimer.current);
pressTimer.current = null;
};
const triggerProps = {
'aria-haspopup': 'true' as const,
'aria-expanded': open,
onPointerDown(e: PointerEvent<HTMLElement>) {
if (e.button !== 0) return;
openedByPress.current = false;
cancelPress();
// cảm ứng tự "capture" pointer vào nút: mọi pointermove/pointerup sau đó đều có target là nút.
// Nhả capture cho target khớp phần tử dưới ngón tay (logic chính vẫn dùng elementFromPoint).
(e.target as Element).releasePointerCapture?.(e.pointerId);
pressTimer.current = setTimeout(() => {
openedByPress.current = true;
sliding.current = true;
openDock(false);
}, longPressMs);
},
onPointerUp() {
cancelPress();
},
onPointerEnter(e: PointerEvent<HTMLElement>) {
if (openOnHoverMs == null || e.pointerType !== 'mouse' || open) return;
cancelPress();
pressTimer.current = setTimeout(() => openDock(false), openOnHoverMs);
},
onPointerLeave() {
// rời nút trước khi đủ thời gian giữ/hover: huỷ (trừ khi dock đã mở và đang trượt)
if (!sliding.current) cancelPress();
},
onClick() {
if (openedByPress.current) return; // click sinh ra sau cú giữ: không tính là "like nhanh"
onSelectRef.current(current ? null : items[0], 'quick');
},
onContextMenu(e: { preventDefault(): void }) {
e.preventDefault(); // giữ lâu trên mobile mở menu ngữ cảnh
},
onKeyDown(e: KeyboardEvent<HTMLElement>) {
if (e.key === 'ArrowUp' || (e.key === 'Enter' && e.altKey)) {
e.preventDefault();
openDock(true);
}
},
};
const dockProps = {
role: 'toolbar' as const,
'aria-label': 'Cảm xúc',
onKeyDown(e: KeyboardEvent<HTMLElement>) {
const rtl = dockRef.current ? getComputedStyle(dockRef.current).direction === 'rtl' : false;
const back = rtl ? 'ArrowRight' : 'ArrowLeft';
const forward = rtl ? 'ArrowLeft' : 'ArrowRight';
if (e.key === back || e.key === forward) {
e.preventDefault();
setFocusIndex((i) => Math.max(0, Math.min(items.length - 1, (i < 0 ? 0 : i) + (e.key === back ? -1 : 1))));
} else if (e.key === 'Escape') {
e.preventDefault();
close(true);
} else if (e.key === 'Tab') {
close(); // để Tab tự đi tiếp tới phần tử kế sau nút
}
},
};
const itemProps = (item: T, index: number) => ({
[ATTR]: item,
'aria-pressed': current === item,
tabIndex: index === (focusIndex < 0 ? Math.max(0, current ? items.indexOf(current) : 0) : focusIndex) ? 0 : -1,
onClick(e: { detail: number }) {
if (blockClicksWhileAnimating && animatingRef.current) return; // dock vừa mọc dưới con trỏ
// Enter/Space trên reaction đang chọn = bỏ chọn (FB: NONE)
const how = e.detail === 0 ? 'keyboard' : 'click';
onSelectRef.current(how === 'keyboard' && current === item ? null : item, how);
close(how === 'keyboard');
},
onFocus() {
setFocusIndex(index);
},
});
return { open, animating, placement, hovered, triggerProps, dockProps, itemProps, close };
}
ts
Khác bản gốc:
- Pointer Events cho cả chuột lẫn cảm ứng, thay cho hai bộ listener
touch*vàmouse*riêng. touch-action: nonetrên nút thay chopreventDefault()trongtouchmove.- Tìm ô bằng
closest(), không giới hạn số cấp. - Bản gốc nhận lựa chọn khi
touchendmà không kiểm tra menu còn hiện (mouseupthì có). Bản viết lại xử lý chuột và cảm ứng cùng một đường. - Phím mở dải cảm xúc từ nút (
↑hoặcAlt+Enter) là lựa chọn của bài viết; bản gốc mở bằng một thành phần khác không có trong đoạn này.
#Đi qua code
onPointerDowntrên nút hẹn giờlongPressMs. Hết giờ mà ngón tay chưa nhấc thì mở dải cảm xúc và đặtsliding = true. Nhấc tay sớm thìonClickchạy như một cú "Thích nhanh".- Khi đang mở,
useEffectgắnpointermove,pointerup,pointercancel,pointerdownlêndocument.pointermovecập nhật ô đang hover.pointeruptrên một ô thì chọn và đóng; thả tay ngoài mọi ô thì giữ dải cảm xúc mở để người dùng chạm chọn (giống FB). openedByPresschặn cúclickmà trình duyệt bắn saupointeruptrên nút, để một cú giữ không bị tính thêm thành "Thích nhanh".openDockđo vị trí nút:top − chiều cao dải − 8px < topInsetthì đặtplacement = 'below'.itemProps.onClickbỏ qua click khianimatingRefcòn bật.event.detail === 0nghĩa là click sinh ra từ bàn phím (Enter/Space trên<button>), lúc đó chọn lại cảm xúc hiện tại nghĩa là bỏ chọn.dockProps.onKeyDownđọcdirectiontừ CSS đã tính để biết đang RTL, rồi đảo phím mũi tên.Escđóng và trả focus về nút;Tabđóng dải cảm xúc để focus đi tiếp tự nhiên (dải cảm xúc ở đây nằm ngay cạnh nút trong DOM nên không cần tự tìm phần tử kế tiếp như FB).
#Tại sao không làm cách khác?
- Nghe
pointerenter/mouseentertrên từng ô. Chạy với chuột, nhưng với cảm ứng (hoặc khi pointer bị capture) các sự kiện này không bắn đúng ô.elementFromPointchạy giống nhau ở mọi loại con trỏ. - Tự tính ô theo toạ độ (
index = floor((x − left) / 46)). Nhanh, nhưng vỡ khi ô phóng to lúc hover, khi RTL, khi đổi kích thước. Hỏi trình duyệt luôn đúng với layout thật. - Debounce click thay vì chặn theo animation. Cùng hiệu quả nhưng tách rời khỏi thời điểm thật sự nguy hiểm. Gắn với trạng thái "đang bật lên" thì rõ nghĩa hơn.
<select>hoặc menu thả xuống. Tiếp cận tốt cho bàn phím nhưng mất thao tác một chạm. Bản này giữ cả hai: trượt cho cảm ứng, roving tabindex cho bàn phím.
#Edge case và cạm bẫy
- Nhấn giữ trên mobile mở menu ngữ cảnh (copy, chia sẻ link) hoặc chọn chữ. Chặn
contextmenutrên nút, thêmuser-select: nonevà-webkit-touch-callout: none. - Ô phóng to khi hover có thể đè lên ô bên cạnh.
elementFromPointtrả về phần tử trên cùng, nên ô đang phóng to sẽ "thắng" vùng giao. Đây thường là hành vi mong muốn. elementFromPointtrảnullkhi toạ độ ra ngoài viewport (ngón tay trượt ra mép màn hình). Luôn kiểm tra trước khi dùng.- Iframe. Toạ độ
clientX/Ylà theo viewport của iframe, vàelementFromPointcũng vậy, nên khớp nhau. Đừng trộn vớipageX/pageY. prefers-reduced-motion. Animation bật lên nên tắt khi người dùng yêu cầu giảm chuyển động. Thời gian chặn click 200ms thì vẫn có thể giữ.
#Dùng trong dự án
const triggerRef = useRef<HTMLButtonElement>(null);
const dockRef = useRef<HTMLDivElement>(null);
const { open, placement, hovered, triggerProps, dockProps, itemProps } = usePressSlide({
items: ['like', 'love', 'haha', 'wow', 'sad', 'angry'] as const,
current: myReaction,
onSelect: (r) => mutateReaction(postId, r),
topInset: headerHeight,
dockRef,
triggerRef,
});
return (
<div className='anchor'>
{open && (
<div ref={dockRef} className={`dock ${placement}`} {...dockProps}>
{REACTIONS.map((r, i) => (
<button key={r} {...itemProps(r, i)} className={hovered === r ? 'hover' : ''}>{EMOJI[r]}</button>
))}
</div>
)}
<button ref={triggerRef} style={{ touchAction: 'none' }} {...triggerProps}>Thích</button>
</div>
);
tsx
#Kiểm thử
elementFromPoint cần layout thật, nên phần trượt nên test bằng trình duyệt (Playwright) thay vì jsdom.
Ví dụ test Playwright (bản phác, không có trong repo)
import { expect, test, type Locator } from '@playwright/test';
const center = async (loc: Locator) => {
const b = (await loc.boundingBox())!;
return { x: b.x + b.width / 2, y: b.y + b.height / 2 };
};
test('nhấn giữ rồi trượt sang Yêu thích', async ({ page }) => {
await page.goto('/#/reaction-dock');
const post = page.locator('.rd-post').first();
const trigger = post.getByTestId('trigger');
const c = await center(trigger);
await page.mouse.move(c.x, c.y);
await page.mouse.down();
await page.waitForTimeout(500); // quá ngưỡng giữ 400ms
const love = await center(post.getByTestId('dock').locator('[data-reaction=love] .rd-glyph'));
await page.mouse.move(love.x, love.y, { steps: 8 });
await page.mouse.up();
await expect(trigger).toHaveText(/Yêu thích/);
});
test('click trong 200ms đầu bị bỏ qua', async ({ page }) => {
await page.goto('/#/reaction-dock');
await page.getByLabel(/rê chuột/).check();
const post = page.locator('.rd-post').first();
const trigger = post.getByTestId('trigger');
const c = await center(trigger);
await page.mouse.move(c.x, c.y);
const dock = post.getByTestId('dock');
await dock.waitFor();
const wow = await center(dock.locator('[data-reaction=wow]'));
await page.mouse.click(wow.x, wow.y);
await expect(trigger).toHaveText(/Thích/);
await page.waitForTimeout(250);
await page.mouse.click(wow.x, wow.y);
await expect(trigger).toHaveText(/Wow/);
});
test('bàn phím: ↑ mở, → chọn ô kế, Enter xác nhận', async ({ page }) => {
await page.goto('/#/reaction-dock');
const trigger = page.locator('.rd-post').first().getByTestId('trigger');
await trigger.focus();
await page.keyboard.press('ArrowUp');
await page.keyboard.press('ArrowRight');
await page.keyboard.press('Enter');
await expect(trigger).toHaveText(/Yêu thích/);
await expect(trigger).toBeFocused();
});
ts
#So với thư viện và chuẩn web
- Pointer Events và
touch-actioncó ở mọi trình duyệt hiện đại, đủ để bỏ hẳn bộ listenertouch*riêng. - WAI-ARIA Toolbar pattern mô tả đúng mô hình roving tabindex mà Facebook dùng: một điểm dừng Tab, mũi tên để di chuyển trong nhóm.
- Thư viện gesture như
@use-gesture/reactlo phần kéo/trượt chung, nhưng vẫn không biết "ô nào nằm dưới ngón tay". PhầnelementFromPointvẫn phải tự viết.
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
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 (bài này)
- #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

