feat(tours): GuidedTour — обобщённый раннер с ожиданием цели
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* GuidedTour — обобщённый раннер экскурсий (спека ИИ-бота §4, этап 3).
|
||||
* Отличия от WelcomeTour: шаги пропсом; цель шага может появиться ПОСЛЕ
|
||||
* действия клиента (открыл диалог) — меряем с ретраем каждые 300мс до 15с.
|
||||
* Разметка/стили — по образцу WelcomeTour (единый вид подсказок).
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import type { TourStep } from '../../tours/catalog';
|
||||
|
||||
const props = defineProps<{ steps: TourStep[]; active: boolean }>();
|
||||
const emit = defineEmits<{ finish: [] }>();
|
||||
|
||||
const RETRY_MS = 300;
|
||||
const RETRY_MAX = 50; // 15 сек
|
||||
|
||||
const stepIndex = ref(0);
|
||||
const targetRect = ref<{ top: number; left: number; width: number; height: number } | null>(null);
|
||||
let retryTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const currentStep = computed(() => props.steps[stepIndex.value]);
|
||||
const isLast = computed(() => stepIndex.value === props.steps.length - 1);
|
||||
|
||||
function stopRetry(): void {
|
||||
if (retryTimer !== null) {
|
||||
clearInterval(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function measure(): void {
|
||||
stopRetry();
|
||||
targetRect.value = null;
|
||||
const sel = currentStep.value?.target;
|
||||
if (!sel) return;
|
||||
let attempts = 0;
|
||||
const tryMeasure = (): void => {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
targetRect.value = { top: r.top, left: r.left, width: r.width, height: r.height };
|
||||
stopRetry();
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts >= RETRY_MAX) stopRetry();
|
||||
};
|
||||
tryMeasure();
|
||||
if (targetRect.value === null) {
|
||||
retryTimer = setInterval(tryMeasure, RETRY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
const highlightStyle = computed(() => {
|
||||
const r = targetRect.value;
|
||||
if (!r) return { display: 'none' };
|
||||
const pad = 6;
|
||||
return {
|
||||
top: `${r.top - pad}px`,
|
||||
left: `${r.left - pad}px`,
|
||||
width: `${r.width + pad * 2}px`,
|
||||
height: `${r.height + pad * 2}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const tooltipStyle = computed(() => {
|
||||
const r = targetRect.value;
|
||||
if (!r) return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
|
||||
return { top: `${Math.max(12, r.top)}px`, left: `${r.left + r.width + 16}px` };
|
||||
});
|
||||
|
||||
function next(): void {
|
||||
if (isLast.value) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
stepIndex.value += 1;
|
||||
measure();
|
||||
}
|
||||
|
||||
function finish(): void {
|
||||
stopRetry();
|
||||
emit('finish');
|
||||
}
|
||||
|
||||
function onResize(): void {
|
||||
if (props.active) measure();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
(on) => {
|
||||
if (on) {
|
||||
stepIndex.value = 0;
|
||||
requestAnimationFrame(() => measure());
|
||||
window.addEventListener('resize', onResize);
|
||||
} else {
|
||||
stopRetry();
|
||||
window.removeEventListener('resize', onResize);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopRetry();
|
||||
window.removeEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
defineExpose({ stepIndex, targetRect, next, finish });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="active && currentStep" class="guided-tour" data-testid="guided-tour">
|
||||
<div class="guided-tour__backdrop" />
|
||||
<div v-if="targetRect" class="guided-tour__highlight" :style="highlightStyle" />
|
||||
<div class="guided-tour__card" :style="tooltipStyle" role="dialog" aria-modal="true">
|
||||
<div class="guided-tour__step">Шаг {{ stepIndex + 1 }} из {{ steps.length }}</div>
|
||||
<h3 class="guided-tour__title">{{ currentStep.title }}</h3>
|
||||
<p class="guided-tour__text">{{ currentStep.text }}</p>
|
||||
<div class="guided-tour__actions">
|
||||
<v-btn variant="text" size="small" data-testid="tour-skip" @click="finish">Закрыть</v-btn>
|
||||
<v-btn color="primary" variant="flat" size="small" data-testid="tour-next" @click="next">
|
||||
{{ isLast ? 'Готово' : 'Далее' }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.guided-tour {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.guided-tour__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(1, 32, 25, 0.55);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.guided-tour__highlight {
|
||||
position: absolute;
|
||||
border: 2px solid var(--liderra-teal, #0f6e56);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 9999px rgba(1, 32, 25, 0.55);
|
||||
transition: all 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.guided-tour__card {
|
||||
position: absolute;
|
||||
width: 300px;
|
||||
max-width: calc(100vw - 24px);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.guided-tour__step {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7470;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
.guided-tour__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 4px 0 6px;
|
||||
color: #081319;
|
||||
}
|
||||
.guided-tour__text {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: #3a423f;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.guided-tour__actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import GuidedTour from '../../resources/js/components/layout/GuidedTour.vue';
|
||||
import type { TourStep } from '../../resources/js/tours/catalog';
|
||||
|
||||
const steps: TourStep[] = [
|
||||
{ route: '/projects', target: '[data-tour="a"]', title: 'Шаг 1', text: 'т1' },
|
||||
{ route: '/projects', target: '[data-tour="b"]', title: 'Шаг 2', text: 'т2' },
|
||||
];
|
||||
|
||||
function mountTour() {
|
||||
return mount(GuidedTour, {
|
||||
props: { steps, active: true },
|
||||
global: { stubs: { 'v-btn': { template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('GuidedTour', () => {
|
||||
it('показывает первый шаг и счётчик', () => {
|
||||
const w = mountTour();
|
||||
expect(w.text()).toContain('Шаг 1');
|
||||
expect(w.text()).toContain('1 из 2');
|
||||
});
|
||||
|
||||
it('Далее ведёт по шагам, на последнем — Готово и finish', async () => {
|
||||
const w = mountTour();
|
||||
await w.find('[data-testid="tour-next"]').trigger('click');
|
||||
expect(w.text()).toContain('Шаг 2');
|
||||
await w.find('[data-testid="tour-next"]').trigger('click');
|
||||
expect(w.emitted('finish')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Пропустить завершает тур сразу', async () => {
|
||||
const w = mountTour();
|
||||
await w.find('[data-testid="tour-skip"]').trigger('click');
|
||||
expect(w.emitted('finish')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('цель не найдена → карточка по центру (targetRect null), без падения', () => {
|
||||
const w = mountTour();
|
||||
expect(w.find('[data-testid="guided-tour"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ретрай измерения: цель появляется позже — подсветка находит её', async () => {
|
||||
vi.useFakeTimers();
|
||||
const w = mountTour();
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('data-tour', 'a');
|
||||
document.body.appendChild(el);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect((w.vm as any).targetRect).not.toBeNull();
|
||||
el.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user