iter(g53): 新增 tour 引导漫游组件

This commit is contained in:
HIsheR
2026-10-26 12:17:22 +08:00
parent bb8c44df60
commit 697d354703
12 changed files with 339 additions and 10 deletions
+1
View File
@@ -51,3 +51,4 @@ export { autoComplete } from './autoComplete.js';
export { datePicker } from './datePicker.js';
export { backTop } from './backTop.js';
export { colorPicker } from './colorPicker.js';
export { tour } from './tour.js';
+102
View File
@@ -0,0 +1,102 @@
import { h, signal } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import { pushOverlay } from './overlay.js';
import { button } from './button.js';
import type { TourHandle, TourProps } from './types.js';
/*** 引导漫游:聚光框 + 说明卡逐步指引,聚光用超大 box-shadow 挖洞省掉遮罩层,Esc / 跳过可退出 ***/
export function tour(props: TourProps): TourHandle {
// —— 状态集中声明 ——
const idx: Signal<number> = signal(-1); // 当前步骤,-1 表示未开启
const pad = props.padding ?? 6; // 聚光外扩像素
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
let lastFocus: HTMLElement | null = null; // 开启前焦点宿主,关闭时归还
const spot = h('div', { class: 'ua-tour-spot', 'aria-hidden': 'true' }); // 聚光框:阴影挖洞
const title = h('div', { class: 'ua-tour-title' });
const desc = h('div', { class: 'ua-tour-desc' });
const counter = h('span', { class: 'ua-tour-counter' });
const prevBtn = button({ label: '上一步', variant: 'ghost', onClick: () => go(idx() - 1) });
const nextBtn = button({ label: '下一步', variant: 'primary', onClick: () => go(idx() + 1) });
const card = h(
'div',
{ class: 'ua-tour-card', role: 'dialog', 'aria-modal': 'false', 'aria-label': '功能引导' },
h('div', { class: 'ua-tour-head' }, title, h('button', { type: 'button', class: 'ua-tour-close', 'aria-label': '跳过引导', onClick: () => close(true) }, '×')),
desc,
h('div', { class: 'ua-tour-foot' }, counter, h('div', { class: 'ua-tour-actions' }, prevBtn, nextBtn)),
) as HTMLElement;
const root = h('div', { class: 'ua-tour' }, spot, card) as HTMLElement;
/*** 定位:目标矩形外扩 pad 作为聚光框,卡片优先放下方、空间不足落上方 ***/
const place = (): void => {
const step = props.steps[idx()];
const target = step ? (document.querySelector(step.target) as HTMLElement | null) : null;
if (!step || !target) return; // 卫语句:目标不存在时保持原位
const r = target.getBoundingClientRect();
spot.style.left = `${r.left - pad}px`;
spot.style.top = `${r.top - pad}px`;
spot.style.width = `${r.width + pad * 2}px`;
spot.style.height = `${r.height + pad * 2}px`;
const below = r.bottom + 12; // 卡片默认贴目标下沿
const fits = below + card.offsetHeight < window.innerHeight; // 下方放得下
card.style.top = `${fits ? below : Math.max(r.top - card.offsetHeight - 12, 8)}px`;
card.style.left = `${Math.min(Math.max(r.left, 12), window.innerWidth - card.offsetWidth - 12)}px`; // 左右夹在视口内
};
/*** 渲染当前步:标题 / 说明 / 计数 / 按钮可用性,末步按钮文案改为「完成」 ***/
const render = (): void => {
const i = idx();
const step = props.steps[i];
if (!step) return; // 卫语句:未开启不渲染
title.textContent = step.title;
desc.textContent = step.description ?? '';
counter.textContent = `${i + 1} / ${props.steps.length}`;
const last = i === props.steps.length - 1;
nextBtn.textContent = last ? '完成' : '下一步';
(prevBtn as HTMLButtonElement).disabled = i === 0; // 首步没有上一步
root.classList.add('ua-open');
requestAnimationFrame(place); // 等卡片尺寸落定再定位,避免首帧位置偏
};
/*** 跳步:越界即结束,-1 之上为合法区间 ***/
const go = (to: number): void => {
if (to < 0) return; // 卫语句:越界不起作用
if (to >= props.steps.length) { finish(); return; }
idx(to);
render();
};
/*** 正常走完:收起并回调 onFinish ***/
const finish = (): void => {
close(false);
props.onFinish?.();
};
/*** 关闭:清 Esc 栈、撤销类名、焦点归还,跳过与走完区分回调 ***/
const close = (skipped: boolean): void => {
if (idx() < 0) return; // 卫语句:未开启不重复关闭
idx(-1);
root.classList.remove('ua-open');
releaseEsc?.();
releaseEsc = null;
lastFocus?.focus();
if (skipped) props.onClose?.();
};
const onViewport = (): void => { if (idx() >= 0) place(); }; // 滚动/缩放后重算位置
window.addEventListener('resize', onViewport);
window.addEventListener('scroll', onViewport, { passive: true });
return {
el: root,
/*** 开启引导:记录焦点宿主、入 Esc 栈、摘掉旧监听后从第一步播放 ***/
start: (): void => {
if (!props.steps.length) return; // 卫语句:无步骤不启动
lastFocus = document.activeElement as HTMLElement | null;
releaseEsc?.();
releaseEsc = pushOverlay(() => close(true)); // 与弹层共用逐层关闭
go(0);
},
close: () => close(true),
};
}
+22
View File
@@ -568,4 +568,26 @@ export interface ColorPickerProps {
onChange?: (color: string) => void; // 选定回调(色板或自定义输入)
}
/*** 引导步骤 ***/
export interface TourStep {
target: string; // 目标元素选择器(document.querySelector 语义)
title: string; // 步骤标题
description?: string; // 补充说明文案
}
/*** 引导漫游入参 ***/
export interface TourProps {
steps: TourStep[]; // 步骤序列,按数组顺序逐步展示
padding?: number; // 聚光区相对目标的外扩像素;缺省 6
onFinish?: () => void; // 走完最后一步的回调
onClose?: () => void; // 中途跳过的回调
}
/*** 引导漫游句柄 ***/
export interface TourHandle {
el: HTMLElement; // 根节点:需由调用方挂到 body(fixed 定位,不占布局)
start: () => void; // 从第一步开始(可重复调用重播)
close: () => void; // 立即关闭
}
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方
+38 -1
View File
@@ -65,7 +65,8 @@
"autoComplete(props):输入联想,输入即过滤候选、↑↓ 环形高亮 / Enter 选中 / Esc 收起,options 可传函数做远程联想",
"datePicker(props):日期选择,单月网格 + 翻月 / 今天快捷键,value 为 YYYY-MM-DD 信号,min/max 闭区间限制,方向键逐日逐周可导航",
"backTop(props):返回顶部悬浮按钮,越过阈值才浮现,外环 conic-gradient 显示已滚比例,点击平滑回顶",
"colorPicker(props):颜色选择,预设色板(缺省语义令牌色)+ 原生取色器 + 手输 CSS 颜色串,value 为颜色串信号"
"colorPicker(props):颜色选择,预设色板(缺省语义令牌色)+ 原生取色器 + 手输 CSS 颜色串,value 为颜色串信号",
"tour(props):引导漫游,返回 { el, start, close } 句柄;聚光框按 target 选择器定位,卡片自动选方位,Esc / 跳过走 onClose"
],
"keyTypes": [
"ButtonProps",
@@ -2244,6 +2245,42 @@
"点色块即提交;手输框支持任意 CSS 颜色串(含 var(--ua-color-accent)),回车提交",
"触发器左侧色块始终预览当前值,点击外部或 Esc 收起"
]
},
{
"id": "tour",
"title": "引导漫游 Tour",
"demo": "sec-tour",
"props": [
{
"n": "steps",
"t": "TourStep[]",
"r": 1,
"d": "步骤序列:每步含 target 选择器、title、可选 description"
},
{
"n": "padding",
"t": "number",
"d": "聚光区相对目标的外扩像素;缺省 6"
},
{
"n": "onFinish",
"t": "() => void",
"d": "走完最后一步的回调"
},
{
"n": "onClose",
"t": "() => void",
"d": "中途跳过(× 或 Esc)的回调"
}
],
"example": "const t = tour({ steps: [{ target: '#sec-tour', title: '引导漫游', description: '按选择器定位' }], onFinish: () => toast.success('完成') }); document.body.append(t.el); t.start();",
"steps": [
"tour() 返回 { el, start, close } 句柄:el 必须挂到 body(fixed 定位,不占布局)",
"start() 从第一步播放,可重复调用重播;close() 立即退出并按跳过处理",
"聚光框按 target 选择器定位并外扩 padding,其余区域由超大 box-shadow 压暗(省掉独立遮罩层)",
"卡片优先落在目标下方,空间不足自动翻到上方,左右夹在视口内;滚动 / 缩放会实时重算",
"末步按钮文案变「完成」并触发 onFinish;× 或 Esc 触发 onClose,焦点归还开启前的元素"
]
}
]
}
+79
View File
@@ -3143,3 +3143,82 @@ button.ua-step-dot:hover {
.ua-colorpicker-trigger,
.ua-colorpicker-cell { transition: none; }
}
.ua-tour {
position: fixed;
inset: 0;
z-index: 70;
display: none;
pointer-events: none;
}
.ua-tour.ua-open {
display: block;
}
.ua-tour-spot {
position: absolute;
border-radius: var(--ua-radius-md);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
transition: all 0.2s ease;
}
.ua-tour-card {
position: absolute;
width: 268px;
padding: var(--ua-space-2);
pointer-events: auto;
border: 1px solid var(--ua-color-border);
border-radius: var(--ua-radius-md);
background: var(--ua-color-surface);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.24);
animation: ua-tour-in 0.18s ease both;
}
.ua-tour-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--ua-space-1);
}
.ua-tour-title {
font-size: var(--ua-font-size-sm);
font-weight: 600;
color: var(--ua-color-text);
}
.ua-tour-close {
padding: 0 4px;
font-size: 14px;
line-height: 1;
color: var(--ua-color-text-muted);
background: transparent;
border: none;
cursor: pointer;
}
.ua-tour-close:hover {
color: var(--ua-color-text);
}
.ua-tour-desc {
margin-top: 4px;
font-size: var(--ua-font-size-sm);
line-height: 1.6;
color: var(--ua-color-text-muted);
}
.ua-tour-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ua-space-1);
margin-top: var(--ua-space-2);
}
.ua-tour-counter {
font-size: var(--ua-font-size-sm);
color: var(--ua-color-text-muted);
}
.ua-tour-actions {
display: flex;
gap: var(--ua-space-1);
}
@keyframes ua-tour-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.ua-tour-card { animation: none; }
.ua-tour-spot { transition: none; }
}