103 lines
4.8 KiB
TypeScript
103 lines
4.8 KiB
TypeScript
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';
|
||
import { watchViewport } from './watch.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(); }; // 滚动/缩放后重算位置
|
||
watchViewport(root, window, onViewport); // 节点脱离文档即自摘监听:引导层反复挂载也不累积
|
||
|
||
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),
|
||
};
|
||
}
|