<script setup lang="ts">
import { ref } from 'vue'
import { Button, Inline, Input, Tour, type TourStep } from '@hina-ui/vue'
const open = ref(false)
const step = ref(1)
const steps: TourStep[] = [
{ title: '欢迎使用', description: '花半分钟认识一下工作台的几个入口。' },
{ target: '#tour-hero-create', title: '新建项目', description: '从这里创建第一个项目。' },
{ target: '#tour-hero-search', title: '搜索', description: '按名称或标签查找项目。' },
{
target: '#tour-hero-share',
title: '邀请成员',
description: '把链接发给同事,一起编辑。',
side: 'left',
},
]
function start() {
step.value = 1
open.value = true
}
</script>
<template>
<Inline gap="sm" align="center" wrap>
<Button id="tour-hero-create">新建项目</Button>
<Input id="tour-hero-search" placeholder="搜索项目" class="w-48" />
<Button id="tour-hero-share" variant="outline" tone="neutral">邀请</Button>
<Button variant="ghost" tone="neutral" @click="start">开始引导</Button>
</Inline>
<Tour v-model:open="open" v-model:step="step" :steps="steps" />
</template>
用法
import { Tour } from '@hina-ui/vue'
steps 是步骤列表,每一步用 target 指向页面上的元素,再给出 title 与 description。打开后遮罩盖住页面,只露出目标,卡片贴在目标旁边并用箭头指向它;target 为空或找不到元素时,卡片显示在视口中央。open 与 step 都支持双向绑定,step 从 1 开始。
target 可以是 CSS 选择器、元素、返回目标的函数、{ x, y, width, height } 形式的矩形(例如 DOMRect)、带 getBoundingClientRect() 的虚拟元素,或者由这些组成的数组,数组的高亮区域是所有目标的外接矩形。目标在步骤切换时取值,可以指向打开引导之后才渲染的元素。矩形使用视口坐标;元素与虚拟元素在页面滚动、尺寸变化时跟随更新。
示例
挡住目标与切换校验
高亮的目标默认可以点击和输入,焦点在卡片与目标之间循环。blockTarget 挡住目标,可以设在整个引导或单个步骤上。beforeChange 在切换前调用,返回 false 时停在当前步骤;返回 Promise 时卡片等它结束,期间「下一步」显示加载指示。
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Inline, Input, Text, Tour, type TourStep } from '@hina-ui/vue'
const open = ref(false)
const name = ref('')
const error = ref('')
const steps: TourStep[] = [
{
target: '#tour-interactive-name',
title: '给项目起个名字',
description: '在输入框里写下名字,再点「下一步」。',
},
{
target: '#tour-interactive-save',
title: '保存',
description: '名字会出现在项目列表里。',
blockTarget: true,
},
]
async function beforeChange(next: number, previous: number) {
if (previous !== 1 || next < previous) return true
await new Promise(resolve => setTimeout(resolve, 600))
error.value = name.value.trim() ? '' : '名字不能为空'
return !error.value
}
</script>
<template>
<Inline gap="sm" align="center" wrap>
<Input id="tour-interactive-name" v-model="name" placeholder="项目名称" class="w-56" />
<Button id="tour-interactive-save">保存</Button>
<Button variant="ghost" tone="neutral" @click="open = true">开始引导</Button>
</Inline>
<Tour v-model:open="open" :steps="steps" :before-change="beforeChange" @finish="error = ''">
<template #content="{ step, current }">
<Text tone="muted" size="sm">{{ step.description }}</Text>
<Text v-if="error && current === 1" tone="danger" size="sm">{{ error }}</Text>
</template>
</Tour>
</template>
信标
beacon 让引导打开时先在第一步目标的右上角显示一个脉冲圆点,点击后才打开卡片和遮罩;之后点「下一步」「上一步」在卡片之间直接切换。步骤上的 beacon: true 让导航到这一步时也先停在信标上,beacon: false 让这一步作为入口时不显示信标。没有目标的步骤不显示信标。
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Inline, Tour, type TourStep } from '@hina-ui/vue'
const open = ref(false)
const steps: TourStep[] = [
{ target: '#tour-beacon-export', title: '导出', description: '新增了导出为 PDF。' },
{
target: '#tour-beacon-history',
title: '历史版本',
description: '每次保存都会留下一个版本。',
},
]
</script>
<template>
<Inline gap="lg" align="center" wrap>
<Button id="tour-beacon-export" variant="outline" tone="neutral">导出</Button>
<Button id="tour-beacon-history" variant="outline" tone="neutral">历史版本</Button>
<Button variant="ghost" tone="neutral" @click="open = !open">
{{ open ? '隐藏提示' : '显示新功能' }}
</Button>
</Inline>
<Tour v-model:open="open" :steps="steps" beacon />
</template>
自定义内容
content 插槽替换说明,footer 插槽替换底部的进度与按钮,两者都会拿到当前步骤与 next、prev、skip。locked 隐藏关闭按钮,Esc 也不再跳过引导。
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Indicator, Inline, Kbd, Text, Tour, type TourStep } from '@hina-ui/vue'
const open = ref(false)
const steps: TourStep[] = [
{ target: '#tour-custom-command', title: '命令面板', description: '随时打开命令面板。' },
{ target: '#tour-custom-theme', title: '主题', description: '在浅色与深色之间切换。' },
]
</script>
<template>
<Inline gap="sm" align="center" wrap>
<Button id="tour-custom-command" variant="outline" tone="neutral">命令</Button>
<Button id="tour-custom-theme" variant="outline" tone="neutral">主题</Button>
<Button variant="ghost" tone="neutral" @click="open = true">开始引导</Button>
</Inline>
<Tour v-model:open="open" :steps="steps" locked>
<template #content="{ step, current }">
<Text tone="muted" size="sm">{{ step.description }}</Text>
<Text v-if="current === 1" size="sm" class="mt-2">
按
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
打开。
</Text>
</template>
<template #footer="{ current, total, next, prev }">
<Inline gap="sm" align="center" justify="between">
<Inline gap="xs" aria-hidden="true">
<Indicator
v-for="index in total"
:key="index"
size="sm"
:tone="index === current ? 'accent' : 'neutral'"
:class="index === current ? undefined : 'opacity-25'"
/>
</Inline>
<Inline gap="sm">
<Button v-if="current > 1" size="sm" variant="ghost" tone="neutral" @click="prev">
返回
</Button>
<Button size="sm" @click="next">{{ current === total ? '知道了' : '继续' }}</Button>
</Inline>
</Inline>
</template>
</Tour>
</template>
不使用遮罩
:mask="false" 去掉遮罩,页面在引导期间仍可操作,卡片不再是模态对话框。mask="dim" 保留遮罩,只把页面变暗,不做模糊。
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Inline, Tour, type TourStep } from '@hina-ui/vue'
const open = ref(false)
const steps: TourStep[] = [
{ target: '#tour-plain-filter', title: '筛选', description: '页面在引导期间仍可操作。' },
{ target: '#tour-plain-sort', title: '排序', description: '点击切换升序与降序。' },
]
</script>
<template>
<Inline gap="sm" align="center" wrap>
<Button id="tour-plain-filter" variant="outline" tone="neutral">筛选</Button>
<Button id="tour-plain-sort" variant="outline" tone="neutral">排序</Button>
<Button variant="ghost" tone="neutral" @click="open = true">开始引导</Button>
</Inline>
<Tour v-model:open="open" :steps="steps" :mask="false" />
</template>
演示脚本
步骤的 play 在进入这一步时执行,可以是异步函数,用来在真实界面上演示操作:打开面板、移动高亮、驱动虚拟指针。它收到的上下文:
signal:离开这一步或关闭引导时中止。defer(cleanup):登记清理函数。离开这一步、关闭引导或重新演示时,先中止signal,再按登记的逆序执行清理。spotlight(target?, options?):在这一步内把高亮移到另一个目标,卡片跟随移动;省略target时回到步骤的目标,传null时收起高亮、卡片居中。options可以带padding、radius与wait,高亮停稳后 Promise 完成。pointer:虚拟指针,见下文。waitFor(target, timeout?):等待目标出现,返回是否出现,默认最多等 5000ms。sleep(ms):等待一段时间。
signal 中止后,sleep、spotlight、waitFor 与指针方法以 AbortError 拒绝,脚本随之停止,引导忽略这个错误。
带 play 的步骤在卡片底部显示「重新演示」按钮。它与 replay() 相同:执行清理,等清理引起的动画结束后,把高亮移回步骤的目标,再重新运行脚本。
blockPage 让整个页面(包括高亮区域)在这一步不响应点击与输入,卡片仍然可以操作,可以设在整个引导或单个步骤上。
第 12 页
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Drawer, Inline, List, ListItem, Text, Tour, type TourStep } from '@hina-ui/vue'
const open = ref(false)
const step = ref(1)
const contents = ref(false)
const page = ref(12)
const steps: TourStep[] = [
{
target: '#tour-play-contents',
title: '目录',
description: '点这里打开目录。',
play: async ({ pointer }) => {
await pointer.move('#tour-play-contents')
await pointer.tap()
},
},
{
target: '#tour-play-chapter',
title: '跳到章节',
description: '目录里点章节名就能跳过去。',
wait: 2000,
play: async ({ pointer, defer, waitFor }) => {
contents.value = true
defer(() => (contents.value = false))
await waitFor('#tour-play-chapter')
await pointer.move('#tour-play-chapter')
await pointer.tap()
},
},
{
target: '#tour-play-page',
title: '翻页',
description: '按 ← 和 → 翻页。',
play: async ({ pointer, sleep }) => {
await pointer.key('→')
page.value += 1
await sleep(400)
await pointer.key('←')
page.value -= 1
},
},
]
function start() {
step.value = 1
open.value = true
}
</script>
<template>
<Inline gap="sm" align="center" wrap>
<Button id="tour-play-contents" variant="outline" tone="neutral" @click="contents = true">
目录
</Button>
<Text id="tour-play-page" tone="muted" class="tabular-nums">第 {{ page }} 页</Text>
<Button variant="ghost" tone="neutral" @click="start">看看怎么用</Button>
</Inline>
<Drawer v-model:open="contents" title="目录">
<template #content>
<List>
<ListItem>第一章 春分</ListItem>
<ListItem>第二章 远雷</ListItem>
<ListItem id="tour-play-chapter">第三章 雨夜</ListItem>
</List>
</template>
</Drawer>
<Tour v-model:open="open" v-model:step="step" :steps="steps" mask="dim" block-page />
</template>
虚拟指针
pointer 在引导上方画出一个不拦截点击的指针。它只做展示,不派发事件;需要真实操作时由脚本完成,例如修改状态或调用 click()。
| 方法 | 说明 |
|---|---|
move(target, duration?) | 移到目标中心或 { x, y },默认 600ms;隐藏时从目标右下方淡入移入 |
tap() | 按下再松开,带涟漪 |
press(duration?) | 长按,默认 800ms,期间显示扩散的圆 |
drag(target, duration?, onMove?) | 按住拖到目标,默认 800ms; onMove 每帧收到指针坐标,可以据此派发真实事件 |
key(key, duration?) | 显示按键提示,默认 700ms;指针可见时显示在指针旁,否则显示在视口底部 |
hide() | 隐藏指针 |
指针方法在这一步的高亮停稳、卡片出现之后才开始。移动与拖动的终点每帧重新读取,目标在移动时指针跟着它走。目标不存在时方法以错误拒绝,要等界面更新后才出现的目标先用 waitFor() 等它出现。
指针外观默认按 (pointer: coarse) 选择:精确指针显示鼠标箭头,触屏显示手指圆点;pointer 属性可以固定为 'mouse' 或 'touch'。开启减少动态效果时,移动与拖动直接到达终点,onMove 只收到终点坐标。离开这一步时指针隐藏。
等待目标
wait 是等待目标出现的最长毫秒数。切换到这一步时目标还不存在,例如要等脚本打开面板,卡片先隐藏,高亮停在上一步的位置;目标出现后卡片直接出现在新位置。超时仍未出现时卡片居中显示。
与模态浮层共存
引导叠放在 Dialog、Drawer、Sheet、菜单等浮层之上,层级为 --hn-z-overlay 加 1。脚本打开的模态浮层把引导当作自身的一部分:点击卡片或遮罩不会关闭浮层,卡片不会对读屏器隐藏,焦点留在卡片上。页面上自绘的其他图层可以用 OverlayBranch 得到同样的处理。
行为
- 打开时焦点移到卡片上,关闭后回到打开前的位置。
- 有遮罩时卡片是模态的:页面其余部分对读屏器隐藏,Tab 只在卡片内循环;目标没有被
blockTarget挡住时也算进循环。 - ← 与 → 切换上一步与下一步,在 RTL 下互换;Esc 跳过引导并触发
skip与close事件。按键在引导打开期间全局生效,焦点不必在卡片上;输入框里的方向键不受影响。没有play与blockPage的步骤里,从目标打开的浮层先处理按键。点击遮罩不会关闭引导。 - 最后一步的按钮是「完成」,点击后触发
finish事件并关闭。 - 切换步骤时,不在视口内的目标会被滚动到中央;
scroll: false关闭这一行为。目标移动、尺寸变化或页面滚动时,遮罩与卡片跟随更新。 - 切换步骤时卡片在原处淡出,内容保持为上一步;新目标还在动画中时高亮先等它停下,再以弹簧动画移过去,停稳后卡片直接出现在新位置。同一步内用
spotlight()移动高亮时,卡片跟随移动。 - 目标元素被移除时高亮停在原处;以选择器或函数指定的目标重新出现后,高亮移到新的元素上。
- 高亮区域出现时从目标中心展开,切到没有目标的步骤时在原处收拢到目标中心,展开与收拢都用 300ms。
- 卡片在目标一侧放不下时翻到另一侧,仍放不下时平移进视口,必要时压在目标上。
- 页面不会停止滚动;引导在服务端不输出内容。
无障碍
- 卡片是
role="dialog",有遮罩时带aria-modal,名称取自步骤标题,说明关联到aria-describedby。 - 等待
beforeChange期间卡片带有aria-busy。 - 信标是一个按钮,名称包含步骤序号与标题。
- 虚拟指针与按键提示对读屏器隐藏。
- 按钮文字、进度与信标名称取自当前语言,可以替换。
API
Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
steps | TourStep[] | 必填 | 步骤列表 |
v-model:open | boolean | false | 是否打开 |
v-model:step | number | 1 | 当前步骤,从 1 开始 |
mask | boolean | 'dim' | true | 显示遮罩,卡片为模态; 'dim' 只变暗不模糊 |
locked | boolean | false | 隐藏关闭按钮,Esc 不跳过引导 |
beacon | boolean | false | 打开时先显示信标,点击后打开卡片 |
blockTarget | boolean | false | 挡住所有步骤的目标 |
blockPage | boolean | false | 整个页面不响应点击与输入,卡片除外 |
pointer | 'auto' | 'mouse' | 'touch' | 'auto' | 虚拟指针的外观 |
beforeChange | TourBeforeChange | — | 切换前调用,返回 false 时停在当前步骤 |
nextText | string | 当前语言 | 「下一步」按钮的文字 |
prevText | string | 当前语言 | 「上一步」按钮的文字 |
finishText | string | 当前语言 | 最后一步按钮的文字 |
skipText | string | 当前语言 | 关闭按钮的名称 |
replayText | string | 当前语言 | 「重新演示」按钮的名称 |
dir | 'ltr' | 'rtl' | 继承 | 阅读方向 |
class | string | — | 追加至卡片的类名 |
TourBeforeChange 为 (step: number, previousStep: number) => boolean | void | Promise<boolean | void>。
TourStep
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
target | TourTarget | — | 高亮的目标,见用法,为空时卡片居中 |
title | string | — | 标题 |
description | string | — | 说明 |
side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | 卡片相对目标的方向 |
align | 'start' | 'center' | 'end' | 'center' | 卡片与目标的对齐方式 |
padding | number | 8 | 高亮区域向外扩展的距离,像素 |
radius | number | 8 | 高亮区域的圆角,像素 |
scroll | boolean | true | 目标不在视口内时滚动到中央 |
blockTarget | boolean | 继承 | 挡住这一步的目标 |
blockPage | boolean | 继承 | 这一步锁定整个页面 |
wait | number | 0 | 等待目标出现的最长毫秒数 |
play | (context: TourPlayContext) => void | Promise<void> | — | 进入这一步时执行的演示脚本 |
beacon | boolean | — | 见信标 |
插槽
| 插槽 | 参数 | 说明 |
|---|---|---|
content | TourSlotProps | 替换说明 |
footer | TourSlotProps | 替换底部的进度与按钮 |
TourSlotProps 为 { step, current, total, busy, playing, next, prev, skip, replay },playing 表示脚本正在运行。
事件
| 事件 | 参数 | 说明 |
|---|---|---|
update:open | open: boolean | 打开或关闭 |
update:step | step: number | 切换步骤 |
finish | — | 在最后一步点击「完成」 |
skip | — | 点击关闭按钮或按 Esc |
stepEnter | step: number | 进入一个步骤,在脚本开始前 |
stepLeave | step: number | 离开一个步骤,在清理之后 |
close | reason: TourCloseReason | 引导关闭,原因为 'finish'、'skip'、'escape' 或 'close'(close() 或外部关闭) |
Expose
next()、prev()、goTo(step)、close() 与 replay() 可通过组件引用访问。前三个会经过 beforeChange;close() 直接关闭,不触发 finish 与 skip;replay() 重新运行当前步骤的脚本。