tour-guide
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
Chinesetour-guide — In-app spotlight tour (no library)
tour-guide — 应用内 spotlight tour(无库)
Pattern đã chạy thật trong bonbon DMS. Một component duy nhất + quy ước + mảng mỗi route.
data-tourspots该模式已在 bonbon DMS 中实际运行。采用单一 component + 约定 + 每个 route 的 数组。
data-tourspotsWHAT
是什么
Purpose và context
目的与上下文
- Purpose: thêm một in-app product tour (spotlight onboarding overlay) tự viết, 0 thư viện: lớp tối + khoét spotlight quanh từng phần tử theo CSS selector, tooltip nhãn, pill "tap để đóng" ở khoảng trống lớn nhất, responsive mobile/desktop.
- Trigger (when to use): user muốn "thêm tour guide", "onboarding tour", "hướng dẫn trong app", "spotlight tour", "product tour", "walkthrough", "tour kiểu bonbon", hoặc invoke . Hợp với React/Next.js + Tailwind.
/tour-guide - Non-goals: không dùng thư viện tour (joyride/intro.js/driver.js); không phải tour nhiều bước next/prev — một overlay hiện mọi điểm dừng của route, click đâu cũng đóng.
- 目的: 添加一个自行编写的应用内 product tour(spotlight 新手引导遮罩),0 个库:暗色遮罩 + 根据 CSS selector 在每个元素周围制作聚光灯镂空区域、标签 tooltip,以及放在最大空白区域中的“点击关闭”pill,响应式适配 mobile/desktop。
- 触发条件(何时使用): 用户希望“添加 tour guide”“onboarding tour”“应用内引导”“spotlight tour”“product tour”“walkthrough”“bonbon 风格 tour”,或调用 。适用于 React/Next.js + Tailwind。
/tour-guide - 非目标: 不使用 tour 库(joyride/intro.js/driver.js);不是带 next/prev 的多步骤 tour——一个 overlay 同时显示当前 route 的所有停留点,点击任意位置即可关闭。
Mental model
心智模型
- Đánh dấu phần tử cần highlight bằng (ổn định hơn class).
data-tour="..." - Một state + nút trigger (vd tap logo) bật/tắt.
tourActive - Mỗi route khai báo (selector + label) truyền vào
spots: TourSpot[].<TourGuide> - Component đo DOM, vẽ SVG dim + spotlight cutout + tooltip, click đâu cũng đóng.
- 使用 标记需要 highlight 的元素(比 class 更稳定)。
data-tour="..." - 使用一个 state 和一个 trigger 按钮(例如点击 logo)开启/关闭。
tourActive - 每个 route 声明 (selector + label),并传给
spots: TourSpot[]。<TourGuide> - Component 测量 DOM,绘制 SVG dim + spotlight cutout + tooltip,点击任意位置即可关闭。
Input và output contract
输入与输出约定
| Field | Required? | Ý nghĩa | |
|---|---|---|---|
| In | app React/Next.js (+ Tailwind) | có | nơi đặt component |
| In | danh sách điểm dừng mỗi route ( | có | mảng |
| In | 6 token màu brand + | không | mặc định trong |
| Out | | có | copy nguyên file, 0 dependency ngoài React |
| Out | thuộc tính | có | tour bật/tắt được |
| 字段 | 必填? | 含义 | |
|---|---|---|---|
| 输入 | 应用 React/Next.js(+ Tailwind) | 是 | 放置 component 的位置 |
| 输入 | 每个 route 的停留点列表( | 是 | |
| 输入 | 6 个品牌颜色 token + | 否 | 默认值位于 |
| 输出 | | 是 | 完整复制该文件,除 React 外 0 依赖 |
| 输出 | 元素上的 | 是 | tour 可开启/关闭 |
Rules và capabilities
规则与能力
Bản gốc không có mục Rules riêng — các luật dưới gom từ "Cách hoạt động", Bước 1–2 và Gotchas (giữ ý nguyên văn):
- RULE-01 (MUST): Không thư viện tour — copy nguyên file component, 0 dependency ngoài React; chỉ chỉnh 6 token màu trong và
TOUR_THEMEqua prop.dismissText - RULE-02 (MUST): Dùng thay vì class — class hay đổi do refactor/Tailwind;
data-tourbền.data-tour - RULE-03 (MUST): Lock scroll: set khi active, nhớ khôi phục khi đóng (đã handle trong cleanup).
document.body.style.overflow='hidden' - RULE-04 (MUST): Click-to-close: toàn overlay ; tooltip/hint là
onClick={onClose}nên không chặn click đóng.pointer-events-none - RULE-05 (SHOULD): Đo trễ 300ms: chờ layout/animation ổn định trước khi đo. Nếu list load async, mở tour sau khi data có (hoặc gọi remeasure).
- Capabilities: sửa code frontend của app (thêm component, thuộc tính, state); đo DOM runtime.
原始内容没有单独的 Rules 章节——以下规则汇总自“工作方式”、步骤 1–2 和 Gotchas,并保留原有含义:
- RULE-01(MUST):不使用 tour 库——完整复制 component 文件,除 React 外 0 依赖;只能通过 prop 修改 中的 6 个颜色 token 和
TOUR_THEME。dismissText - RULE-02(MUST):使用 而不是 class——class 经常因 refactor/Tailwind 而变化,而
data-tour更稳定。data-tour - RULE-03(MUST):锁定滚动: active 时设置 ,关闭时记得恢复(cleanup 中已处理)。
document.body.style.overflow='hidden' - RULE-04(MUST):点击关闭: 整个 overlay 使用 ;tooltip/hint 使用
onClick={onClose},因此不会阻止点击关闭。pointer-events-none - RULE-05(SHOULD):延迟 300ms 测量: 等待 layout/animation 稳定后再测量。如果列表异步加载,应在数据可用后打开 tour(或调用 remeasure)。
- 能力:修改应用的 frontend 代码(添加 component、属性和 state);在 runtime 中测量 DOM。
Failure boundaries
失败边界
- Selector không khớp / phần tử khuất hoặc off-screen → spot tự bị bỏ qua (không lỗi) — nếu mọi spot bị bỏ thì tour trống: failed, kiểm lại hoặc thời điểm mở tour.
data-tour - App có lớp z-index cao hơn → overlay bị che: nâng z-index.
z-[500] - App không phải React → clarify: component là TSX/React, cần port.
- Selector 未匹配、元素被遮挡或位于屏幕外 → spot 自动跳过(不报错)。如果所有 spot 都被跳过,tour 将为空:失败,需要检查 或打开 tour 的时机。
data-tour - 应用中存在高于 的层级 → overlay 会被遮挡,需要提高 z-index。
z-[500] - 应用不是 React → 需要澄清:该 component 使用 TSX/React,需要进行移植。
HOW
如何
Main workflow
主工作流
| Step | Type | Inputs | Action | Outputs/exit | Failure/next |
|---|---|---|---|---|---|
| W01 | effect | app | Bước 1: tạo | component | — |
| W02 | effect | phần tử cần highlight | Bước 2: thêm | selector ổn định | — |
| W03 | effect | route | Bước 3: state | tour chạy trên route | layout 2 dạng → B01 |
| W04 | judgment | trang chạy | Mở tour, kiểm spotlight/tooltip/pill đúng chỗ trên mobile + desktop, click đâu cũng đóng, scroll khôi phục | tour đạt | spot mất → kiểm Gotchas |
Chi tiết từng bước (nguồn chân lý cho W01–W04):
| 步骤 | 类型 | 输入 | 操作 | 输出/退出 | 失败/下一步 |
|---|---|---|---|---|---|
| W01 | effect | 应用 | 步骤 1:创建 | component | — |
| W02 | effect | 需要 highlight 的元素 | 步骤 2:添加 | 稳定的 selector | — |
| W03 | effect | route | 步骤 3:state | tour 在 route 中运行 | 存在两种 layout → B01 |
| W04 | judgment | 运行中的页面 | 打开 tour,在 mobile 和 desktop 上检查 spotlight/tooltip/pill 的位置,验证点击任意位置都能关闭,且滚动可以恢复 | tour 达标 | spot 丢失 → 检查 Gotchas |
各步骤详情(W01–W04 的事实来源):
Bước 1 — Component (copy nguyên file)
步骤 1 — Component(完整复制文件)
Tạo (hoặc nơi tương đương). 0 dependency ngoài React.
Chỉnh 6 token màu trong cho khớp brand; đổi qua prop.
components/ui/tour-guide.tsxTOUR_THEMEdismissTexttsx
"use client";
import { useEffect, useState, useCallback } from "react";
/** Sửa 6 token này cho khớp brand của bạn. */
const TOUR_THEME = {
scrim: "rgba(0,0,0,0.72)", // lớp tối
ring: "rgba(255,255,255,0.2)", // viền spotlight
tipDesktop: "border border-sky-400 bg-white text-slate-900 shadow-[0_2px_8px_rgba(56,189,248,0.2)]",
tipMobile: "bg-sky-100 text-slate-900",
hintDesktop: "bg-slate-900 text-white",
hintMobile: "bg-white/25 text-white backdrop-blur-sm",
};
export interface TourSpot {
selector: string; // CSS selector phần tử (ưu tiên [data-tour='...'])
label: string; // chữ tooltip ("" nếu chỉ muốn spotlight)
labelSide?: "top" | "bottom" | "right"; // hướng đặt tooltip
spotlightOnly?: boolean; // chỉ khoét spotlight, không tooltip
tooltipOnly?: boolean; // chỉ tooltip, không khoét spotlight
}
interface MeasuredSpot {
top: number; left: number; width: number; height: number;
label: string; labelSide: "top" | "bottom" | "right";
spotlightOnly?: boolean; tooltipOnly?: boolean;
}
function measure(spots: TourSpot[]): MeasuredSpot[] {
return spots.flatMap(spot => {
const el = document.querySelector(spot.selector);
if (!el) return [];
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return [];
if (r.top < -20 || r.top > window.innerHeight) return []; // off-screen → bỏ
const vh = window.innerHeight;
return [{
top: r.top, left: r.left, width: r.width, height: r.height,
label: spot.label,
labelSide: spot.labelSide ?? (r.top > vh * 0.6 ? "top" : "bottom"),
spotlightOnly: spot.spotlightOnly,
tooltipOnly: spot.tooltipOnly,
}];
});
}
export function TourGuide({
active, onClose, spots, dismissText = "Tap anywhere to close",
}: {
active: boolean; onClose: () => void; spots: TourSpot[]; dismissText?: string;
}) {
const [maskId] = useState(() => `tm-${Math.random().toString(36).slice(2, 8)}`);
const [rects, setRects] = useState<MeasuredSpot[]>([]);
const remeasure = useCallback(() => { if (active) setRects(measure(spots)); }, [active, spots]);
useEffect(() => {
if (!active) { setRects([]); document.body.style.overflow = ""; return; }
window.scrollTo({ top: 0, behavior: "instant" });
document.body.style.overflow = "hidden";
const id = setTimeout(() => setRects(measure(spots)), 300) as unknown as number;
window.addEventListener("resize", remeasure);
return () => {
clearTimeout(id);
window.removeEventListener("resize", remeasure);
document.body.style.overflow = "";
};
}, [active, remeasure]);
if (!active) return null;
const PAD = 8;
const vw = window.innerWidth;
const vh = window.innerHeight;
const isDesktop = vw >= 768;
return (
<div className="fixed inset-0 z-[500]" onClick={onClose}>
<svg className="pointer-events-none absolute inset-0" width={vw} height={vh}>
<defs>
<mask id={maskId}>
<rect width="100%" height="100%" fill="white" />
{rects.filter(r => !r.tooltipOnly).map((r, i) => (
<rect key={i} x={r.left - PAD} y={r.top - PAD}
width={r.width + PAD * 2} height={r.height + PAD * 2} rx={12} fill="black" />
))}
</mask>
</defs>
<rect width="100%" height="100%" fill={TOUR_THEME.scrim} mask={`url(#${maskId})`} />
{rects.filter(r => !r.tooltipOnly).map((r, i) => (
<rect key={i} x={r.left - PAD} y={r.top - PAD}
width={r.width + PAD * 2} height={r.height + PAD * 2} rx={12}
fill="none" stroke={TOUR_THEME.ring} strokeWidth={1.5} />
))}
</svg>
{rects.filter(r => !r.spotlightOnly).map((r, i) => {
const isTop = r.labelSide === "top";
const isRight = r.labelSide === "right";
const isTall = r.height > 50 && r.height <= 200;
const tip = `whitespace-nowrap rounded-lg px-2.5 py-1 text-[10px] font-semibold shadow-md ${isDesktop ? TOUR_THEME.tipDesktop : TOUR_THEME.tipMobile}`;
let tooltipTop: number;
if (isRight) {
const estWR = Math.min(r.label.length * 7 + 24, 220);
tooltipTop = r.top + r.height / 2 - 14;
const rightLeft = r.left + r.width + PAD + 6;
return (
<div key={i} className="pointer-events-none fixed z-[501]"
style={{ top: tooltipTop, left: Math.min(rightLeft, vw - estWR - 8) }}>
<div className={tip}>{r.label}</div>
</div>
);
} else if (isTop) {
tooltipTop = Math.max(8, r.top - 30);
} else if (isTall) {
tooltipTop = Math.min(r.top + r.height * 0.82, vh - 40);
} else if (r.height > 200 && (!isDesktop || r.tooltipOnly)) {
return null; // card mở rộng (mobile luôn ẩn; desktop chỉ ẩn khi tooltipOnly)
} else {
tooltipTop = Math.min(r.top + r.height + 6, vh - 40);
}
const estW = Math.min(r.label.length * 7 + 24, 220);
const centerX = r.left + r.width / 2 - estW / 2;
const tooltipLeft = Math.max(8, Math.min(centerX, vw - estW - 8));
return (
<div key={i} className="pointer-events-none fixed z-[501]"
style={{ top: tooltipTop, left: tooltipLeft }}>
<div className={tip}>{r.label}</div>
</div>
);
})}
{(() => {
const PILL_H = 40, MARGIN = 16;
const zones: [number, number][] = rects.map(r => [r.top - PAD - 36, r.top + r.height + PAD + 36]);
zones.sort((a, b) => a[0] - b[0]);
const merged: [number, number][] = [];
for (const z of zones) {
if (merged.length && z[0] <= merged[merged.length - 1][1]) {
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], z[1]);
} else { merged.push([...z] as [number, number]); }
}
const gaps: { top: number; bot: number; size: number }[] = [];
let prev = 80;
for (const [zTop, zBot] of merged) {
if (zTop - prev >= PILL_H + MARGIN * 2) gaps.push({ top: prev, bot: zTop, size: zTop - prev });
prev = zBot;
}
if (vh - prev >= PILL_H + MARGIN * 2) gaps.push({ top: prev, bot: vh, size: vh - prev });
const best = gaps.sort((a, b) => b.size - a.size)[0];
if (!best || best.size < PILL_H + MARGIN * 2) return null;
const pillTop = best.top + (best.size - PILL_H) / 2;
return (
<div className={`pointer-events-none fixed left-1/2 z-[501] -translate-x-1/2 rounded-full px-5 py-2.5 text-xs font-semibold shadow-lg ${isDesktop ? TOUR_THEME.hintDesktop : TOUR_THEME.hintMobile}`}
style={{ top: pillTop }}>
{dismissText}
</div>
);
})()}
</div>
);
}创建 (或同等位置)。除 React 外 0 依赖。
调整 中的 6 个颜色 token 以匹配品牌;通过 prop 修改 。
components/ui/tour-guide.tsxTOUR_THEMEdismissTexttsx
"use client";
import { useEffect, useState, useCallback } from "react";
/** Sửa 6 token này cho khớp brand của bạn. */
const TOUR_THEME = {
scrim: "rgba(0,0,0,0.72)", // lớp tối
ring: "rgba(255,255,255,0.2)", // viền spotlight
tipDesktop: "border border-sky-400 bg-white text-slate-900 shadow-[0_2px_8px_rgba(56,189,248,0.2)]",
tipMobile: "bg-sky-100 text-slate-900",
hintDesktop: "bg-slate-900 text-white",
hintMobile: "bg-white/25 text-white backdrop-blur-sm",
};
export interface TourSpot {
selector: string; // CSS selector phần tử (ưu tiên [data-tour='...'])
label: string; // chữ tooltip ("" nếu chỉ muốn spotlight)
labelSide?: "top" | "bottom" | "right"; // hướng đặt tooltip
spotlightOnly?: boolean; // chỉ khoét spotlight, không tooltip
tooltipOnly?: boolean; // chỉ tooltip, không khoét spotlight
}
interface MeasuredSpot {
top: number; left: number; width: number; height: number;
label: string; labelSide: "top" | "bottom" | "right";
spotlightOnly?: boolean; tooltipOnly?: boolean;
}
function measure(spots: TourSpot[]): MeasuredSpot[] {
return spots.flatMap(spot => {
const el = document.querySelector(spot.selector);
if (!el) return [];
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return [];
if (r.top < -20 || r.top > window.innerHeight) return []; // off-screen → bỏ
const vh = window.innerHeight;
return [{
top: r.top, left: r.left, width: r.width, height: r.height,
label: spot.label,
labelSide: spot.labelSide ?? (r.top > vh * 0.6 ? "top" : "bottom"),
spotlightOnly: spot.spotlightOnly,
tooltipOnly: spot.tooltipOnly,
}];
});
}
export function TourGuide({
active, onClose, spots, dismissText = "Tap anywhere to close",
}: {
active: boolean; onClose: () => void; spots: TourSpot[]; dismissText?: string;
}) {
const [maskId] = useState(() => `tm-${Math.random().toString(36).slice(2, 8)}`);
const [rects, setRects] = useState<MeasuredSpot[]>([]);
const remeasure = useCallback(() => { if (active) setRects(measure(spots)); }, [active, spots]);
useEffect(() => {
if (!active) { setRects([]); document.body.style.overflow = ""; return; }
window.scrollTo({ top: 0, behavior: "instant" });
document.body.style.overflow = "hidden";
const id = setTimeout(() => setRects(measure(spots)), 300) as unknown as number;
window.addEventListener("resize", remeasure);
return () => {
clearTimeout(id);
window.removeEventListener("resize", remeasure);
document.body.style.overflow = "";
};
}, [active, remeasure]);
if (!active) return null;
const PAD = 8;
const vw = window.innerWidth;
const vh = window.innerHeight;
const isDesktop = vw >= 768;
return (
<div className="fixed inset-0 z-[500]" onClick={onClose}>
<svg className="pointer-events-none absolute inset-0" width={vw} height={vh}>
<defs>
<mask id={maskId}>
<rect width="100%" height="100%" fill="white" />
{rects.filter(r => !r.tooltipOnly).map((r, i) => (
<rect key={i} x={r.left - PAD} y={r.top - PAD}
width={r.width + PAD * 2} height={r.height + PAD * 2} rx={12} fill="black" />
))}
</mask>
</defs>
<rect width="100%" height="100%" fill={TOUR_THEME.scrim} mask={`url(#${maskId})`} />
{rects.filter(r => !r.tooltipOnly).map((r, i) => (
<rect key={i} x={r.left - PAD} y={r.top - PAD}
width={r.width + PAD * 2} height={r.height + PAD * 2} rx={12}
fill="none" stroke={TOUR_THEME.ring} strokeWidth={1.5} />
))}
</svg>
{rects.filter(r => !r.spotlightOnly).map((r, i) => {
const isTop = r.labelSide === "top";
const isRight = r.labelSide === "right";
const isTall = r.height > 50 && r.height <= 200;
const tip = `whitespace-nowrap rounded-lg px-2.5 py-1 text-[10px] font-semibold shadow-md ${isDesktop ? TOUR_THEME.tipDesktop : TOUR_THEME.tipMobile}`;
let tooltipTop: number;
if (isRight) {
const estWR = Math.min(r.label.length * 7 + 24, 220);
tooltipTop = r.top + r.height / 2 - 14;
const rightLeft = r.left + r.width + PAD + 6;
return (
<div key={i} className="pointer-events-none fixed z-[501]"
style={{ top: tooltipTop, left: Math.min(rightLeft, vw - estWR - 8) }}>
<div className={tip}>{r.label}</div>
</div>
);
} else if (isTop) {
tooltipTop = Math.max(8, r.top - 30);
} else if (isTall) {
tooltipTop = Math.min(r.top + r.height * 0.82, vh - 40);
} else if (r.height > 200 && (!isDesktop || r.tooltipOnly)) {
return null; // card mở rộng (mobile luôn ẩn; desktop chỉ ẩn khi tooltipOnly)
} else {
tooltipTop = Math.min(r.top + r.height + 6, vh - 40);
}
const estW = Math.min(r.label.length * 7 + 24, 220);
const centerX = r.left + r.width / 2 - estW / 2;
const tooltipLeft = Math.max(8, Math.min(centerX, vw - estW - 8));
return (
<div key={i} className="pointer-events-none fixed z-[501]"
style={{ top: tooltipTop, left: tooltipLeft }}>
<div className={tip}>{r.label}</div>
</div>
);
})}
{(() => {
const PILL_H = 40, MARGIN = 16;
const zones: [number, number][] = rects.map(r => [r.top - PAD - 36, r.top + r.height + PAD + 36]);
zones.sort((a, b) => a[0] - b[0]);
const merged: [number, number][] = [];
for (const z of zones) {
if (merged.length && z[0] <= merged[merged.length - 1][1]) {
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], z[1]);
} else { merged.push([...z] as [number, number]); }
}
const gaps: { top: number; bot: number; size: number }[] = [];
let prev = 80;
for (const [zTop, zBot] of merged) {
if (zTop - prev >= PILL_H + MARGIN * 2) gaps.push({ top: prev, bot: zTop, size: zTop - prev });
prev = zBot;
}
if (vh - prev >= PILL_H + MARGIN * 2) gaps.push({ top: prev, bot: vh, size: vh - prev });
const best = gaps.sort((a, b) => b.size - a.size)[0];
if (!best || best.size < PILL_H + MARGIN * 2) return null;
const pillTop = best.top + (best.size - PILL_H) / 2;
return (
<div className={`pointer-events-none fixed left-1/2 z-[501] -translate-x-1/2 rounded-full px-5 py-2.5 text-xs font-semibold shadow-lg ${isDesktop ? TOUR_THEME.hintDesktop : TOUR_THEME.hintMobile}`}
style={{ top: pillTop }}>
{dismissText}
</div>
);
})()}
</div>
);
}Bước 2 — Đánh dấu phần tử bằng data-tour
data-tour步骤 2 — 使用 data-tour
标记元素
data-tourTrên các phần tử muốn highlight, thêm attribute ổn định:
tsx
<button data-tour="logo" onClick={() => setTourActive(true)}>…</button>
<input data-tour="search-input" … />
<div data-tour="list-item" … />Dùngthay vì class — class hay đổi do refactor/Tailwind;data-tourbền.data-tour
在需要 highlight 的元素上添加稳定的 attribute:
tsx
<button data-tour="logo" onClick={() => setTourActive(true)}>…</button>
<input data-tour="search-input" … />
<div data-tour="list-item" … />使用而不是 class——class 经常因 refactor/Tailwind 而变化,而data-tour更稳定。data-tour
Bước 3 — Wire vào route (state + trigger + spots)
步骤 3 — 接入 route(state + trigger + spots)
tsx
import { TourGuide, type TourSpot } from "@/components/ui/tour-guide";
import { useMemo, useState } from "react";
const [tourActive, setTourActive] = useState(false);
const SPOTS = useMemo<TourSpot[]>(() => [
{ selector: "[data-tour='logo']", label: "Mở hướng dẫn", labelSide: "bottom" },
{ selector: "[data-tour='search-input']", label: "Tìm kiếm ở đây", labelSide: "bottom" },
{ selector: "[data-tour='list-item']", label: "Nhấn để xem chi tiết", labelSide: "bottom" },
{ selector: "[data-tour='action-approve']", label: "Duyệt", labelSide: "top" },
], []);
// …trong JSX:
<button data-tour="logo" onClick={() => setTourActive(true)}>Hướng dẫn</button>
<TourGuide active={tourActive} onClose={() => setTourActive(false)} spots={SPOTS} dismissText="Nhấn bất kỳ đâu để đóng" />tsx
import { TourGuide, type TourSpot } from "@/components/ui/tour-guide";
import { useMemo, useState } from "react";
const [tourActive, setTourActive] = useState(false);
const SPOTS = useMemo<TourSpot[]>(() => [
{ selector: "[data-tour='logo']", label: "Mở hướng dẫn", labelSide: "bottom" },
{ selector: "[data-tour='search-input']", label: "Tìm kiếm ở đây", labelSide: "bottom" },
{ selector: "[data-tour='list-item']", label: "Nhấn để xem chi tiết", labelSide: "bottom" },
{ selector: "[data-tour='action-approve']", label: "Duyệt", labelSide: "top" },
], []);
// …trong JSX:
<button data-tour="logo" onClick={() => setTourActive(true)}>Hướng dẫn</button>
<TourGuide active={tourActive} onClose={() => setTourActive(false)} spots={SPOTS} dismissText="Nhấn bất kỳ đâu để đóng" />Branches
分支
| ID | Kind | Guard | Hành vi | Skip / failure | Rejoin |
|---|---|---|---|---|---|
| B01 | conditional_required | layout mobile và desktop khác nhau | khai 2 mảng spots (selector | trộn 1 mảng vẫn chạy vì | W04 |
| B02 | conditional_required | list load async | mở tour sau khi data có (hoặc gọi remeasure) | — | W04 |
| B03 | user_optional | phần tử quá to / chỉ cần một kiểu | | — | W04 |
| ID | 类型 | 条件 | 行为 | 跳过/失败 | 返回 |
|---|---|---|---|---|---|
| B01 | conditional_required | mobile 和 desktop 的 layout 不同 | 声明两个 spots 数组(使用独立的 | 混用一个数组也可以运行,因为 | W04 |
| B02 | conditional_required | 列表异步加载 | 在数据可用后打开 tour(或调用 remeasure) | — | W04 |
| B03 | user_optional | 元素过大或只需要一种展示方式 | 使用 | — | W04 |
Validation và stopping
验证与停止条件
Kiểm bằng mắt trên trang chạy thật ở cả hai bề rộng (< 768 và ≥ 768): mỗi spot có cutout/tooltip đúng phần tử, pill nằm ở khoảng trống, click bất kỳ đâu đóng, khôi phục sau khi đóng. Dừng khi mọi route đã khai spots hiện đúng.
body.style.overflow通过肉眼在真实运行页面的两种宽度(< 768 和 ≥ 768)下验证:每个 spot 都应在正确元素上显示 cutout/tooltip,pill 应位于空白区域,点击任意位置都能关闭,并且关闭后 会恢复。当所有 route 都已声明并正确显示 spots 后停止。
body.style.overflowExamples
示例
- Positive: màn danh sách đơn có logo, ô tìm kiếm, dòng đơn, nút duyệt → gắn 4 chỗ,
data-tournhư Bước 3 → tap logo mở tour: 4 spotlight kèm nhãn, pill "Nhấn bất kỳ đâu để đóng" ở vùng trống, tap đóng và trang cuộn lại được.SPOTS - Boundary/failure: mở tour ngay khi list còn đang fetch → spot chưa có trong DOM nên bị
[data-tour='list-item']bỏ, tour thiếu điểm → mở tour sau khi data về (B02).measure()
- 正向示例: 单据列表界面包含 logo、搜索框、单据行和审批按钮 → 在 4 个位置添加 ,按照步骤 3 设置
data-tour→ 点击 logo 打开 tour:显示 4 个带标签的 spotlight,“点击任意位置关闭”的 pill 位于空白区域,点击后关闭且页面可以正常滚动。SPOTS - 边界/失败示例: 在列表仍处于 fetch 状态时打开 tour → 尚不在 DOM 中,因此会被
[data-tour='list-item']忽略,导致 tour 缺少该停留点 → 等待数据返回后再打开 tour(B02)。measure()
Reference — TourSpot
参考 — TourSpot
| field | ý nghĩa |
|---|---|
| CSS selector (ưu tiên |
| chữ tooltip; |
| |
| khoét spotlight, KHÔNG tooltip |
| tooltip nổi, KHÔNG khoét (cho phần tử quá to / desktop) |
| 字段 | 含义 |
|---|---|
| CSS selector(优先使用 |
| tooltip 文本; |
| |
| 镂空 spotlight,不显示 tooltip |
| 显示浮动 tooltip,不镂空(适合过大元素/desktop) |
Reference — Mobile vs Desktop
参考 — Mobile 与 Desktop
Vì layout 2 dạng khác nhau, khai báo 2 mảng spots (selector riêng) rồi chọn theo (hoặc ). Phần tử desktop ẩn trên mobile sẽ tự bị bỏ qua, nên trộn chung 1 mảng cũng chạy — nhưng tách rõ ràng dễ chỉnh nhãn hơn.
data-tour='desktop-*'window.innerWidth >= 768useMediaQuerymeasure()由于两种 layout 不同,应声明两个 spots 数组(使用独立的 selector),然后根据 选择(或使用 )。隐藏在 mobile 上的 desktop 元素会被 自动忽略,因此混用一个数组也可以运行——但将两者分开更便于调整标签。
data-tour='desktop-*'window.innerWidth >= 768useMediaQuerymeasure()Reference — Gotchas
参考 — Gotchas
- z-index: overlay , tooltip/hint
z-[500]. Nâng nếu app có thứ cao hơn.z-[501] - Đo trễ 300ms: chờ layout/animation ổn định trước khi đo. Nếu list load async, mở tour sau khi data có (hoặc gọi remeasure).
- Lock scroll: set khi active, nhớ khôi phục khi đóng (đã handle trong cleanup).
document.body.style.overflow='hidden' - Click-to-close: toàn overlay ; tooltip/hint là
onClick={onClose}nên không chặn click đóng.pointer-events-none - Element khuất: spot off-screen tự loại — nên cuộn lên đầu () trước khi đo (đã làm).
scrollTo top
- z-index: overlay 为 ,tooltip/hint 为
z-[500]。如果应用中存在更高层级,需要提高 z-index。z-[501] - 延迟 300ms 测量: 等待 layout/animation 稳定后再测量。如果列表异步加载,应在数据可用后打开 tour(或调用 remeasure)。
- 锁定滚动: active 时设置 ,关闭时记得恢复(cleanup 中已处理)。
document.body.style.overflow='hidden' - 点击关闭: 整个 overlay 使用 ;tooltip/hint 使用
onClick={onClose},因此不会阻止点击关闭。pointer-events-none - 元素被遮挡: 位于屏幕外的 spot 会被自动忽略——因此测量前应滚动到顶部(代码中已实现)。
Origin
来源
Trích & generalize từ bonbon DMS (đã chạy production, mobile+desktop).
components/dms/tour-guide.tsx提取并通用化自 bonbon DMS 的 (已在 production 中运行,支持 mobile 和 desktop)。
components/dms/tour-guide.tsx