tour-guide

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

tour-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
data-tour
+ mảng
spots
mỗi route.
该模式已在 bonbon DMS 中实际运行。采用单一 component +
data-tour
约定 + 每个 route 的
spots
数组。

WHAT

是什么

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
    /tour-guide
    . Hợp với React/Next.js + Tailwind.
  • 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”,或调用
    /tour-guide
    。适用于 React/Next.js + Tailwind。
  • 非目标: 不使用 tour 库(joyride/intro.js/driver.js);不是带 next/prev 的多步骤 tour——一个 overlay 同时显示当前 route 的所有停留点,点击任意位置即可关闭。

Mental model

心智模型

  1. Đánh dấu phần tử cần highlight bằng
    data-tour="..."
    (ổn định hơn class).
  2. Một state
    tourActive
    + nút trigger (vd tap logo) bật/tắt.
  3. Mỗi route khai báo
    spots: TourSpot[]
    (selector + label) truyền vào
    <TourGuide>
    .
  4. Component đo DOM, vẽ SVG dim + spotlight cutout + tooltip, click đâu cũng đóng.
  1. 使用
    data-tour="..."
    标记需要 highlight 的元素(比 class 更稳定)。
  2. 使用一个
    tourActive
    state 和一个 trigger 按钮(例如点击 logo)开启/关闭。
  3. 每个 route 声明
    spots: TourSpot[]
    (selector + label),并传给
    <TourGuide>
    。
  4. Component 测量 DOM,绘制 SVG dim + spotlight cutout + tooltip,点击任意位置即可关闭。

Input và output contract

输入与输出约定

FieldRequired?Ý nghĩa
Inapp React/Next.js (+ Tailwind)cónơi đặt component
Indanh sách điểm dừng mỗi route (
selector
+
label
, tuỳ
labelSide
/
spotlightOnly
/
tooltipOnly
)
cómảng
spots: TourSpot[]
In6 token màu brand +
dismissText
khôngmặc định trong
TOUR_THEME
/ prop
Out
components/ui/tour-guide.tsx
(hoặc nơi tương đương)
cócopy nguyên file, 0 dependency ngoài React
Outthuộc tính
data-tour
trên phần tử + state
tourActive
+ nút trigger +
<TourGuide>
mỗi route
cótour bật/tắt được
字段必填?含义
输入应用 React/Next.js(+ Tailwind)是放置 component 的位置
输入每个 route 的停留点列表(
selector
+
label
,可选
labelSide
/
spotlightOnly
/
tooltipOnly
)
是
spots: TourSpot[]
数组
输入6 个品牌颜色 token +
dismissText
否默认值位于
TOUR_THEME
或通过 prop 提供
输出
components/ui/tour-guide.tsx
(或同等位置)
是完整复制该文件,除 React 外 0 依赖
输出元素上的
data-tour
属性 +
tourActive
state + trigger 按钮 + 每个 route 的
<TourGuide>
是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
    TOUR_THEME
    và
    dismissText
    qua prop.
  • RULE-02 (MUST): Dùng
    data-tour
    thay vì class — class hay đổi do refactor/Tailwind;
    data-tour
    bền.
  • RULE-03 (MUST): Lock scroll: set
    document.body.style.overflow='hidden'
    khi active, nhớ khôi phục khi đóng (đã handle trong cleanup).
  • RULE-04 (MUST): Click-to-close: toàn overlay
    onClick={onClose}
    ; tooltip/hint là
    pointer-events-none
    nên không chặn click đóng.
  • 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 修改
    TOUR_THEME
    中的 6 个颜色 token 和
    dismissText
    。
  • RULE-02(MUST):使用
    data-tour
    而不是 class——class 经常因 refactor/Tailwind 而变化,而
    data-tour
    更稳定。
  • RULE-03(MUST):锁定滚动: active 时设置
    document.body.style.overflow='hidden'
    ,关闭时记得恢复(cleanup 中已处理)。
  • RULE-04(MUST):点击关闭: 整个 overlay 使用
    onClick={onClose}
    ;tooltip/hint 使用
    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
    data-tour
    hoặc thời điểm mở tour.
  • App có lớp z-index cao hơn
    z-[500]
    → overlay bị che: nâng z-index.
  • App không phải React → clarify: component là TSX/React, cần port.
  • Selector 未匹配、元素被遮挡或位于屏幕外 → spot 自动跳过(不报错)。如果所有 spot 都被跳过,tour 将为空:失败,需要检查
    data-tour
    或打开 tour 的时机。
  • 应用中存在高于
    z-[500]
    的层级 → overlay 会被遮挡,需要提高 z-index。
  • 应用不是 React → 需要澄清:该 component 使用 TSX/React,需要进行移植。

HOW

如何

Main workflow

主工作流

StepTypeInputsActionOutputs/exitFailure/next
W01effectappBước 1: tạo
components/ui/tour-guide.tsx
(copy nguyên file), chỉnh
TOUR_THEME
component—
W02effectphần tử cần highlightBước 2: thêm
data-tour="..."
selector ổn định—
W03effectrouteBước 3: state
tourActive
+ nút trigger + mảng
SPOTS
+
<TourGuide>
tour chạy trên routelayout 2 dạng → B01
W04judgmenttrang chạyMở tour, kiểm spotlight/tooltip/pill đúng chỗ trên mobile + desktop, click đâu cũng đóng, scroll khôi phụctour đạtspot mất → kiểm Gotchas
Chi tiết từng bước (nguồn chân lý cho W01–W04):
步骤类型输入操作输出/退出失败/下一步
W01effect应用步骤 1:创建
components/ui/tour-guide.tsx
(完整复制文件),调整
TOUR_THEME
component—
W02effect需要 highlight 的元素步骤 2:添加
data-tour="..."
稳定的 selector—
W03effectroute步骤 3:state
tourActive
+ trigger 按钮 +
SPOTS
数组 +
<TourGuide>
tour 在 route 中运行存在两种 layout → B01
W04judgment运行中的页面打开 tour,在 mobile 和 desktop 上检查 spotlight/tooltip/pill 的位置,验证点击任意位置都能关闭,且滚动可以恢复tour 达标spot 丢失 → 检查 Gotchas
各步骤详情(W01–W04 的事实来源):

Bước 1 — Component (copy nguyên file)

步骤 1 — Component(完整复制文件)

Tạo
components/ui/tour-guide.tsx
(hoặc nơi tương đương). 0 dependency ngoài React. Chỉnh 6 token màu trong
TOUR_THEME
cho khớp brand;
dismissText
đổi qua prop.
tsx
"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>
  );
}
创建
components/ui/tour-guide.tsx
(或同等位置)。除 React 外 0 依赖。 调整
TOUR_THEME
中的 6 个颜色 token 以匹配品牌;通过 prop 修改
dismissText
。
tsx
"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

步骤 2 — 使用
data-tour
标记元素

Trê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ùng
data-tour
thay vì class — class hay đổi do refactor/Tailwind;
data-tour
bền.
在需要 highlight 的元素上添加稳定的 attribute:
tsx
<button data-tour="logo" onClick={() => setTourActive(true)}>…</button>
<input data-tour="search-input" … />
<div  data-tour="list-item" … />
使用
data-tour
而不是 class——class 经常因 refactor/Tailwind 而变化,而
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

分支

IDKindGuardHành viSkip / failureRejoin
B01conditional_requiredlayout mobile và desktop khác nhaukhai 2 mảng spots (selector
data-tour='desktop-*'
riêng), chọn theo
window.innerWidth >= 768
(hoặc
useMediaQuery
)
trộn 1 mảng vẫn chạy vì
measure()
bỏ phần tử ẩn
W04
B02conditional_requiredlist load asyncmở tour sau khi data có (hoặc gọi remeasure)—W04
B03user_optionalphần tử quá to / chỉ cần một kiểu
tooltipOnly
(không khoét) hoặc
spotlightOnly
+
label: ""
(không tooltip)
—W04
ID类型条件行为跳过/失败返回
B01conditional_requiredmobile 和 desktop 的 layout 不同声明两个 spots 数组(使用独立的
data-tour='desktop-*'
selector),根据
window.innerWidth >= 768
选择(或使用
useMediaQuery
)
混用一个数组也可以运行,因为
measure()
会忽略隐藏元素
W04
B02conditional_required列表异步加载在数据可用后打开 tour(或调用 remeasure)—W04
B03user_optional元素过大或只需要一种展示方式使用
tooltipOnly
(不镂空)或
spotlightOnly
+
label: ""
(不显示 tooltip)
—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,
body.style.overflow
khôi phục sau khi đóng. Dừng khi mọi route đã khai spots hiện đúng.
通过肉眼在真实运行页面的两种宽度(< 768 和 ≥ 768)下验证:每个 spot 都应在正确元素上显示 cutout/tooltip,pill 应位于空白区域,点击任意位置都能关闭,并且关闭后
body.style.overflow
会恢复。当所有 route 都已声明并正确显示 spots 后停止。

Examples

示例

  • Positive: màn danh sách đơn có logo, ô tìm kiếm, dòng đơn, nút duyệt → gắn
    data-tour
    4 chỗ,
    SPOTS
    như 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.
  • Boundary/failure: mở tour ngay khi list còn đang fetch → spot
    [data-tour='list-item']
    chưa có trong DOM nên bị
    measure()
    bỏ, tour thiếu điểm → mở tour sau khi data về (B02).
  • 正向示例: 单据列表界面包含 logo、搜索框、单据行和审批按钮 → 在 4 个位置添加
    data-tour
    ,按照步骤 3 设置
    SPOTS
    → 点击 logo 打开 tour:显示 4 个带标签的 spotlight,“点击任意位置关闭”的 pill 位于空白区域,点击后关闭且页面可以正常滚动。
  • 边界/失败示例: 在列表仍处于 fetch 状态时打开 tour →
    [data-tour='list-item']
    尚不在 DOM 中,因此会被
    measure()
    忽略,导致 tour 缺少该停留点 → 等待数据返回后再打开 tour(B02)。

Reference — TourSpot

参考 — TourSpot

fieldý nghĩa
selector
CSS selector (ưu tiên
[data-tour='x']
); element không tồn tại/khuất → tự bỏ qua
label
chữ tooltip;
""
+
spotlightOnly
= chỉ khoét sáng
labelSide
top
/
bottom
/
right
(mặc định: tự chọn theo vị trí)
spotlightOnly
khoét spotlight, KHÔNG tooltip
tooltipOnly
tooltip nổi, KHÔNG khoét (cho phần tử quá to / desktop)
字段含义
selector
CSS selector(优先使用
[data-tour='x']
);元素不存在或被隐藏时自动跳过
label
tooltip 文本;
""
+
spotlightOnly
表示只镂空高亮
labelSide
top
/
bottom
/
right
(默认根据位置自动选择)
spotlightOnly
镂空 spotlight,不显示 tooltip
tooltipOnly
显示浮动 tooltip,不镂空(适合过大元素/desktop)

Reference — Mobile vs Desktop

参考 — Mobile 与 Desktop

Vì layout 2 dạng khác nhau, khai báo 2 mảng spots (selector
data-tour='desktop-*'
riêng) rồi chọn theo
window.innerWidth >= 768
(hoặc
useMediaQuery
). Phần tử desktop ẩn trên mobile sẽ tự bị
measure()
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.
由于两种 layout 不同,应声明两个 spots 数组(使用独立的
data-tour='desktop-*'
selector),然后根据
window.innerWidth >= 768
选择(或使用
useMediaQuery
)。隐藏在 mobile 上的 desktop 元素会被
measure()
自动忽略,因此混用一个数组也可以运行——但将两者分开更便于调整标签。

Reference — Gotchas

参考 — Gotchas

  • z-index: overlay
    z-[500]
    , tooltip/hint
    z-[501]
    . Nâng nếu app có thứ cao hơn.
  • Đ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
    document.body.style.overflow='hidden'
    khi active, nhớ khôi phục khi đóng (đã handle trong cleanup).
  • Click-to-close: toàn overlay
    onClick={onClose}
    ; tooltip/hint là
    pointer-events-none
    nên không chặn click đóng.
  • Element khuất: spot off-screen tự loại — nên cuộn lên đầu (
    scrollTo top
    ) trước khi đo (đã làm).
  • z-index: overlay 为
    z-[500]
    ,tooltip/hint 为
    z-[501]
    。如果应用中存在更高层级,需要提高 z-index。
  • 延迟 300ms 测量: 等待 layout/animation 稳定后再测量。如果列表异步加载,应在数据可用后打开 tour(或调用 remeasure)。
  • 锁定滚动: active 时设置
    document.body.style.overflow='hidden'
    ,关闭时记得恢复(cleanup 中已处理)。
  • 点击关闭: 整个 overlay 使用
    onClick={onClose}
    ;tooltip/hint 使用
    pointer-events-none
    ,因此不会阻止点击关闭。
  • 元素被遮挡: 位于屏幕外的 spot 会被自动忽略——因此测量前应滚动到顶部(代码中已实现)。

Origin

来源

Trích & generalize từ bonbon DMS
components/dms/tour-guide.tsx
(đã chạy production, mobile+desktop).
提取并通用化自 bonbon DMS 的
components/dms/tour-guide.tsx
(已在 production 中运行,支持 mobile 和 desktop)。