Files
UI/packages/skin/ui/src/tour.ts
T
2026-10-26 12:17:22 +08:00

103 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
};
}