diff --git a/packages/varlet-ui/src/locale/en-US.ts b/packages/varlet-ui/src/locale/en-US.ts index 1224d0a38eb..254f2cdc926 100644 --- a/packages/varlet-ui/src/locale/en-US.ts +++ b/packages/varlet-ui/src/locale/en-US.ts @@ -1,6 +1,12 @@ import { Message } from './index' export default { + // Tour + tourTitle: 'Tour', + tourPrevButtonText: 'Previous', + tourNextButtonText: 'Next', + tourFinishButtonText: 'Finish', + tourCloseButtonText: 'Skip', // Dialog dialogTitle: 'Hint', dialogConfirmButtonText: 'Confirm', diff --git a/packages/varlet-ui/src/locale/fa-IR.ts b/packages/varlet-ui/src/locale/fa-IR.ts index 54f96ce8e93..f88b6b12283 100644 --- a/packages/varlet-ui/src/locale/fa-IR.ts +++ b/packages/varlet-ui/src/locale/fa-IR.ts @@ -1,6 +1,12 @@ import { Message } from './index' export default { + // Tour + tourTitle: 'راهنما', + tourPrevButtonText: 'قبلی', + tourNextButtonText: 'بعدی', + tourFinishButtonText: 'پایان', + tourCloseButtonText: 'رد کردن', // Dialog dialogTitle: 'اشاره', dialogConfirmButtonText: 'تایید', diff --git a/packages/varlet-ui/src/locale/index.ts b/packages/varlet-ui/src/locale/index.ts index 9591101afb2..34eb21bb8ca 100644 --- a/packages/varlet-ui/src/locale/index.ts +++ b/packages/varlet-ui/src/locale/index.ts @@ -9,6 +9,12 @@ import zhHK from './zh-HK' import zhTW from './zh-TW' export type Message = { + // Tour + tourTitle?: string + tourPrevButtonText?: string + tourNextButtonText?: string + tourFinishButtonText?: string + tourCloseButtonText?: string // Dialog dialogTitle: string dialogConfirmButtonText: string diff --git a/packages/varlet-ui/src/locale/ja-JP.ts b/packages/varlet-ui/src/locale/ja-JP.ts index e8fc80c13b8..8eaaec365a3 100644 --- a/packages/varlet-ui/src/locale/ja-JP.ts +++ b/packages/varlet-ui/src/locale/ja-JP.ts @@ -1,6 +1,12 @@ import { Message } from './index' export default { + // Tour + tourTitle: '使い方ガイド', + tourPrevButtonText: '前へ', + tourNextButtonText: '次へ', + tourFinishButtonText: '完了', + tourCloseButtonText: 'スキップ', // Dialog dialogTitle: '確認', dialogConfirmButtonText: '確認', diff --git a/packages/varlet-ui/src/locale/zh-CN.ts b/packages/varlet-ui/src/locale/zh-CN.ts index 147b18b9825..a58dede7338 100644 --- a/packages/varlet-ui/src/locale/zh-CN.ts +++ b/packages/varlet-ui/src/locale/zh-CN.ts @@ -1,6 +1,12 @@ import { Message } from './index' export default { + // Tour + tourTitle: '漫游引导', + tourPrevButtonText: '上一步', + tourNextButtonText: '下一步', + tourFinishButtonText: '完成', + tourCloseButtonText: '跳过', // Dialog dialogTitle: '提示', dialogConfirmButtonText: '确认', diff --git a/packages/varlet-ui/src/locale/zh-TW.ts b/packages/varlet-ui/src/locale/zh-TW.ts index 91d89ac13fe..85a47acf3ab 100644 --- a/packages/varlet-ui/src/locale/zh-TW.ts +++ b/packages/varlet-ui/src/locale/zh-TW.ts @@ -1,6 +1,12 @@ import { Message } from './index' export default { + // Tour + tourTitle: '漫遊引導', + tourPrevButtonText: '上一步', + tourNextButtonText: '下一步', + tourFinishButtonText: '完成', + tourCloseButtonText: '略過', // Dialog dialogTitle: '提示', dialogConfirmButtonText: '確認', diff --git a/packages/varlet-ui/src/tour/Tour.vue b/packages/varlet-ui/src/tour/Tour.vue new file mode 100644 index 00000000000..fe311856ac4 --- /dev/null +++ b/packages/varlet-ui/src/tour/Tour.vue @@ -0,0 +1,518 @@ + + + + + diff --git a/packages/varlet-ui/src/tour/__tests__/index.spec.js b/packages/varlet-ui/src/tour/__tests__/index.spec.js new file mode 100644 index 00000000000..b0ffad0fa2e --- /dev/null +++ b/packages/varlet-ui/src/tour/__tests__/index.spec.js @@ -0,0 +1,466 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { afterEach, expect, test, vi } from 'vite-plus/test' +import { createApp, defineComponent, h, KeepAlive, nextTick, ref } from 'vue' +import { renderToString } from 'vue/server-renderer' +import Tour from '..' +import { use } from '../../locale' +import VarLocaleProvider from '../../locale-provider' +import { triggerKeyboard } from '../../utils/test' +import VarTour from '../Tour.vue' + +const wrappers = [] +const targets = [] +const steps = [ + { target: '#tour-target', title: 'Create', description: 'Create a project' }, + { title: 'Ready', description: 'You can now start working' }, +] + +function createTarget(rect = { x: 100, y: 100, width: 100, height: 40 }) { + const element = document.createElement('button') + element.id = 'tour-target' + element.textContent = 'Target' + element.getBoundingClientRect = vi.fn(() => ({ + ...rect, + left: rect.x, + top: rect.y, + right: rect.x + rect.width, + bottom: rect.y + rect.height, + toJSON: () => ({}), + })) + element.scrollIntoView = vi.fn() + document.body.appendChild(element) + targets.push(element) + return element +} + +async function mountTour(options = {}) { + const wrapper = mount(VarTour, { + attachTo: document.body, + ...options, + props: { + steps, + show: true, + teleport: false, + 'onUpdate:show': vi.fn(), + 'onUpdate:current': vi.fn(), + ...options.props, + }, + }) + wrappers.push(wrapper) + await flushPromises() + return wrapper +} + +afterEach(() => { + wrappers.splice(0).forEach((wrapper) => wrapper.unmount()) + targets.splice(0).forEach((target) => target.remove()) + vi.restoreAllMocks() + vi.unstubAllGlobals() + use('zh-CN') +}) + +test('tour plugin and default props setter', () => { + const app = createApp({}).use(Tour) + expect(app.component(Tour.name)).toBeTruthy() + expect(Tour.setPropsDefaults).toBeTypeOf('function') +}) + +test('tour does not render when closed or steps are empty', async () => { + const wrapper = await mountTour({ props: { show: false } }) + expect(wrapper.find('[role="dialog"]').exists()).toBe(false) + await wrapper.setProps({ show: true, steps: [] }) + expect(wrapper.find('[role="dialog"]').exists()).toBe(false) +}) + +test('tour highlights targets with a padded mask and follows placement', async () => { + createTarget() + const wrapper = await mountTour({ props: { placement: 'top', gap: 10, gapRadius: 6 } }) + const highlight = wrapper.find('.var-tour__highlight') + expect(highlight.element.style).toMatchObject({ + left: '90px', + top: '90px', + width: '120px', + height: '60px', + borderRadius: '6px', + }) + expect(wrapper.find('path').attributes('fill-rule')).toBe('evenodd') + expect(wrapper.find('[role="dialog"]').attributes('data-popper-placement')).toMatch(/top|bottom/) + expect(wrapper.find('.var-tour__title').text()).toBe('Create') + expect(wrapper.find('.var-tour__progress').text()).toBe('1 / 2') +}) + +test('tour next, previous, and finish update models and emit events', async () => { + const onChange = vi.fn() + const onFinish = vi.fn() + const wrapper = await mountTour({ props: { onChange: [onChange], onFinish: [onFinish] } }) + await wrapper.find('.var-tour__next').trigger('click') + await flushPromises() + expect(wrapper.props('onUpdate:current').mock.calls).toEqual([[1]]) + expect(onChange).toHaveBeenLastCalledWith(1, 0) + expect(wrapper.find('.var-tour__title').text()).toBe('Ready') + await wrapper.find('.var-tour__prev').trigger('click') + expect(onChange).toHaveBeenLastCalledWith(0, 1) + wrapper.vm.next() + await flushPromises() + await wrapper.find('.var-tour__next').trigger('click') + expect(onFinish).toHaveBeenCalledTimes(1) + expect(wrapper.props('onUpdate:show').mock.calls).toEqual([[false]]) + expect(wrapper.find('[role="dialog"]').exists()).toBe(false) +}) + +test('tour skip closes without finishing', async () => { + const onOpen = vi.fn() + const onClose = vi.fn() + const onFinish = vi.fn() + const wrapper = await mountTour({ props: { onOpen, onClose, onFinish } }) + expect(onOpen).toHaveBeenCalledTimes(1) + await wrapper.find('.var-tour__close').trigger('click') + expect(onClose).toHaveBeenCalledTimes(1) + expect(onFinish).not.toHaveBeenCalled() +}) + +test('tour open resets the step and guards navigation while closed', async () => { + const wrapper = await mountTour({ props: { show: false } }) + wrapper.vm.next() + wrapper.vm.prev() + wrapper.vm.finish() + expect(wrapper.emitted('finish')).toBeUndefined() + wrapper.vm.open(1) + await flushPromises() + expect(wrapper.find('.var-tour__title').text()).toBe('Ready') + wrapper.vm.close() + await nextTick() + wrapper.vm.open() + await flushPromises() + expect(wrapper.find('.var-tour__title').text()).toBe('Create') +}) + +test('tour clamps invalid indices and shrinking steps', async () => { + const wrapper = await mountTour({ props: { current: 99 } }) + expect(wrapper.find('.var-tour__progress').text()).toBe('2 / 2') + await wrapper.setProps({ steps: [steps[0]] }) + await flushPromises() + expect(wrapper.find('.var-tour__progress').text()).toBe('1 / 1') + await wrapper.setProps({ current: NaN }) + expect(wrapper.find('.var-tour__progress').text()).toBe('1 / 1') + wrapper.vm.open(-3) + await flushPromises() + expect(wrapper.find('.var-tour__progress').text()).toBe('1 / 1') +}) + +test.each([undefined, '#missing', '[', () => null])('tour centers when target is absent: %s', async (target) => { + const wrapper = await mountTour({ props: { steps: [{ ...steps[0], target }] } }) + expect(wrapper.find('.var-tour--center').exists()).toBe(true) + expect(wrapper.find('.var-tour__highlight').exists()).toBe(false) +}) + +test('tour accepts elements and resolver functions and tracks replacement', async () => { + let target = createTarget() + const wrapper = await mountTour({ props: { steps: [{ ...steps[0], target: () => target }] } }) + expect(wrapper.find('.var-tour__highlight').element.style.left).toBe('92px') + target.remove() + target = createTarget({ x: 200, y: 200, width: 50, height: 30 }) + wrapper.vm.resize() + await flushPromises() + expect(wrapper.find('.var-tour__highlight').element.style.left).toBe('192px') + await wrapper.setProps({ steps: [{ ...steps[0], target }] }) + await flushPromises() + expect(wrapper.find('.var-tour__highlight').exists()).toBe(true) +}) + +test('tour centers when target is hidden, removed, or outside the viewport', async () => { + const target = createTarget() + const wrapper = await mountTour() + target.getBoundingClientRect.mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + }) + wrapper.vm.resize() + await flushPromises() + expect(wrapper.find('.var-tour--center').exists()).toBe(true) + target.getBoundingClientRect.mockReturnValue({ + x: -1000, + y: 0, + left: -1000, + top: 0, + right: -900, + bottom: 40, + width: 100, + height: 40, + }) + wrapper.vm.resize() + await nextTick() + expect(wrapper.find('.var-tour__highlight').exists()).toBe(false) + target.remove() + wrapper.vm.resize() + await nextTick() + expect(wrapper.find('.var-tour--center').exists()).toBe(true) +}) + +test('tour scrolls offscreen targets into view unless disabled', async () => { + const target = createTarget({ x: 100, y: 2000, width: 100, height: 40 }) + const wrapper = await mountTour() + expect(target.scrollIntoView).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest', behavior: 'instant' }) + target.scrollIntoView.mockClear() + await wrapper.setProps({ scrollIntoView: false }) + await flushPromises() + expect(target.scrollIntoView).not.toHaveBeenCalled() +}) + +test('tour overlay clicks close only when enabled', async () => { + const onClickOverlay = vi.fn() + const wrapper = await mountTour({ props: { onClickOverlay } }) + await wrapper.find('path').trigger('click') + expect(onClickOverlay).toHaveBeenCalledTimes(1) + expect(wrapper.find('[role="dialog"]').exists()).toBe(true) + await wrapper.setProps({ closeOnClickOverlay: true }) + await wrapper.find('path').trigger('click') + expect(wrapper.props('onUpdate:show').mock.calls).toEqual([[false]]) +}) + +test('tour allows target interaction and supports an overlay-free guide', async () => { + createTarget() + const wrapper = await mountTour() + expect(wrapper.find('.var-tour__highlight').classes()).not.toContain('var-tour--interactive') + expect(wrapper.find('[role="dialog"]').attributes('aria-modal')).toBe('true') + await wrapper.setProps({ allowTargetInteraction: true }) + expect(wrapper.find('.var-tour__highlight').classes()).toContain('var-tour--interactive') + expect(wrapper.find('[role="dialog"]').attributes('aria-modal')).toBeUndefined() + await wrapper.setProps({ overlay: false }) + expect(wrapper.find('svg').exists()).toBe(false) +}) + +test('tour Escape respects configuration and only closes the top tour', async () => { + const first = await mountTour() + const second = await mountTour({ props: { closeOnKeyEscape: false } }) + await triggerKeyboard(window, 'keydown', { key: 'Escape', cancelable: true }) + expect(first.props('onUpdate:show')).not.toHaveBeenCalled() + expect(second.props('onUpdate:show')).not.toHaveBeenCalled() + await second.setProps({ closeOnKeyEscape: true }) + await triggerKeyboard(window, 'keydown', { key: 'Escape', cancelable: true }) + expect(second.props('onUpdate:show').mock.calls).toEqual([[false]]) + expect(first.props('onUpdate:show')).not.toHaveBeenCalled() +}) + +test('tour traps focus and restores the trigger after closing', async () => { + const target = createTarget() + target.focus() + const wrapper = await mountTour() + const panel = wrapper.find('[role="dialog"]').element + const first = wrapper.find('.var-tour__close').element + const last = wrapper.find('.var-tour__next').element + expect(document.activeElement).toBe(panel) + await triggerKeyboard(window, 'keydown', { key: 'Tab', cancelable: true }) + expect(document.activeElement).toBe(first) + await triggerKeyboard(window, 'keydown', { key: 'Tab', shiftKey: true, cancelable: true }) + expect(document.activeElement).toBe(last) + await triggerKeyboard(window, 'keydown', { key: 'Tab', cancelable: true }) + expect(document.activeElement).toBe(first) + target.focus() + expect(document.activeElement).toBe(panel) + wrapper.vm.close() + await flushPromises() + expect(document.activeElement).toBe(target) +}) + +test('tour restores focus on unmount and has accessible labels without a title', async () => { + const target = createTarget() + target.focus() + const wrapper = await mountTour({ props: { steps: [{ description: 'Welcome' }] } }) + expect(wrapper.find('[role="dialog"]').attributes('aria-label')).toBeTruthy() + wrapper.unmount() + wrappers.splice(wrappers.indexOf(wrapper), 1) + expect(document.activeElement).toBe(target) +}) + +test('tour uses scoped locale messages and custom button labels', async () => { + const wrapper = mount(VarLocaleProvider, { + attachTo: document.body, + props: { messages: { 'zh-CN': { tourNextButtonText: 'Continue' } } }, + slots: { default: () => h(VarTour, { show: true, teleport: false, steps }) }, + }) + wrappers.push(wrapper) + await flushPromises() + expect(wrapper.find('.var-tour__next').text()).toBe('Continue') + const custom = await mountTour({ + props: { nextButtonText: 'Custom', closeButtonText: 'Dismiss', showProgress: false }, + }) + expect(custom.find('.var-tour__next').text()).toBe('Custom') + expect(custom.find('.var-tour__close').text()).toBe('Dismiss') + expect(custom.find('.var-tour__progress').exists()).toBe(false) +}) + +test('tour slots receive the current step and navigation methods', async () => { + const wrapper = await mountTour({ + slots: { + title: ({ current }) => `Step ${current + 1}`, + default: ({ step }) => h('strong', step.description), + actions: ({ next }) => h('button', { onClick: next }, 'Continue'), + }, + }) + expect(wrapper.find('.var-tour__title').text()).toBe('Step 1') + expect(wrapper.find('strong').text()).toBe('Create a project') + await wrapper.find('.var-tour__footer button').trigger('click') + expect(wrapper.find('.var-tour__title').text()).toBe('Step 2') +}) + +test('tour disconnects observers and cancels pending layout work when closed', async () => { + const observers = [] + vi.stubGlobal( + 'ResizeObserver', + class { + observe = vi.fn() + disconnect = vi.fn() + constructor(callback) { + this.callback = callback + observers.push(this) + } + }, + ) + const raf = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(123) + const cancel = vi.spyOn(window, 'cancelAnimationFrame') + createTarget() + const wrapper = await mountTour() + expect(observers[0].observe).toHaveBeenCalledTimes(2) + observers[0].callback() + observers[0].callback() + expect(raf).toHaveBeenCalledTimes(1) + wrapper.vm.close() + await flushPromises() + expect(observers[0].disconnect).toHaveBeenCalled() + expect(cancel).toHaveBeenCalledWith(123) +}) + +test('tour follows nested scrolling and resizing in a single frame', async () => { + const target = createTarget() + const wrapper = await mountTour() + const frames = [] + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback) + return 1 + }) + target.getBoundingClientRect.mockReturnValue({ + x: 300, + y: 100, + left: 300, + top: 100, + right: 400, + bottom: 140, + width: 100, + height: 40, + }) + target.dispatchEvent(new Event('scroll')) + window.dispatchEvent(new Event('resize')) + expect(frames).toHaveLength(1) + frames[0]() + await flushPromises() + expect(wrapper.find('.var-tour__highlight').element.style.left).toBe('292px') +}) + +test('tour clips highlights to scroll containers and centers when the target is fully clipped', async () => { + const target = createTarget() + const container = document.createElement('div') + container.style.overflow = 'auto' + container.getBoundingClientRect = () => ({ left: 120, top: 110, right: 180, bottom: 130, width: 60, height: 20 }) + document.body.appendChild(container) + container.appendChild(target) + targets.push(container) + const wrapper = await mountTour({ props: { scrollIntoView: false } }) + expect(wrapper.find('.var-tour__highlight').element.style).toMatchObject({ + left: '120px', + top: '110px', + width: '60px', + height: '20px', + }) + target.getBoundingClientRect.mockReturnValue({ left: 100, top: 130, right: 200, bottom: 170, width: 100, height: 40 }) + wrapper.vm.resize() + await flushPromises() + expect(wrapper.find('.var-tour__highlight').exists()).toBe(false) + expect(wrapper.find('.var-tour--center').exists()).toBe(true) +}) + +test('tour releases scroll locks when closed', async () => { + const wrapper = await mountTour({ props: { lockScroll: true } }) + expect(document.body.classList.contains('var--lock')).toBe(true) + wrapper.vm.close() + await nextTick() + expect(document.body.classList.contains('var--lock')).toBe(false) +}) + +test('tour hides while deactivated and returns on activation', async () => { + const visible = ref(true) + const Host = defineComponent({ + setup: () => () => h(KeepAlive, () => (visible.value ? h(VarTour, { show: true, steps, teleport: false }) : null)), + }) + const wrapper = mount(Host, { attachTo: document.body }) + wrappers.push(wrapper) + await flushPromises() + expect(wrapper.find('[role="dialog"]').exists()).toBe(true) + visible.value = false + await flushPromises() + expect(document.querySelector('[role="dialog"]')).toBeNull() + visible.value = true + await flushPromises() + expect(wrapper.find('[role="dialog"]').exists()).toBe(true) +}) + +test('tour synchronizes controlled visibility and step models without duplicate change events', async () => { + const show = ref(false) + const current = ref(0) + const onChange = vi.fn() + const wrapper = mount( + { + setup: () => () => + h(VarTour, { + show: show.value, + current: current.value, + steps, + teleport: false, + onChange, + 'onUpdate:show': (value) => { + show.value = value + }, + 'onUpdate:current': (value) => { + current.value = value + }, + }), + }, + { attachTo: document.body }, + ) + wrappers.push(wrapper) + show.value = true + await flushPromises() + await wrapper.find('.var-tour__next').trigger('click') + await flushPromises() + expect(current.value).toBe(1) + expect(onChange).toHaveBeenCalledTimes(1) + await wrapper.find('.var-tour__next').trigger('click') + await flushPromises() + expect(show.value).toBe(false) + expect(wrapper.find('[role="dialog"]').exists()).toBe(false) +}) + +test('tour normalizes invalid indices without duplicate change notifications', async () => { + const onChange = vi.fn() + const wrapper = await mountTour({ props: { onChange } }) + await wrapper.setProps({ current: 99 }) + await flushPromises() + expect(wrapper.find('.var-tour__progress').text()).toBe('2 / 2') + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenLastCalledWith(1, 0) +}) + +test('tour supports server rendering without resolving browser targets', async () => { + const target = vi.fn(() => { + throw new Error('must not resolve targets during SSR') + }) + const html = await renderToString(h(VarTour, { show: true, steps: [{ target }], teleport: false })) + expect(html).not.toContain('role="dialog"') + expect(target).not.toHaveBeenCalled() +}) diff --git a/packages/varlet-ui/src/tour/docs/en-US.md b/packages/varlet-ui/src/tour/docs/en-US.md new file mode 100644 index 00000000000..651e8976ece --- /dev/null +++ b/packages/varlet-ui/src/tour/docs/en-US.md @@ -0,0 +1,187 @@ +# Tour + +### Introduction + +Guide users through a page with step descriptions and highlighted targets. The card uses Dialog theme variables and Button styles, including Material Design 2, Material Design 3, and dark themes. + +### Basic Usage + +Use `v-model:show` to control visibility and `steps` to describe the tour. A `target` can be a CSS selector, an HTML element, or a function returning an HTML element. The optional `v-model:current` uses zero-based step indices. + +```html + + + +``` + +### Targets and Placement + +The card appears below its target by default. It flips when space is limited and stays within the viewport. Set a global `placement` or override it for individual steps. +Offscreen targets are scrolled into view when changing steps. Set `scroll-into-view` to `false` to disable this behavior. Positioning follows page and nested container scrolling, window resizing, and target size changes. + +A step without a target displays a centered card, which is useful for welcome and closing screens. Missing, hidden, removed, fully offscreen targets, and invalid selectors also fall back to a centered card without a highlight. + +```html + + + +``` + +Target resolver functions can read reactive element refs. For selector targets added asynchronously, or layout changes that only move an element without scrolling or resizing, call `resize()` after `nextTick()`. Loading pages, changing routes, and persisting tour completion remain the responsibility of the application. + +### Highlight and Interaction + +Set `gap` for space around the target and `gap-radius` for rounded corners. The overlay blocks page interaction by default, including the highlighted area. Enable `allow-target-interaction` to make the highlighted target interactive. Set `overlay` to `false` to keep the card and highlight without a mask. + +```html + +``` + +Overlay clicks do not close the tour unless `close-on-click-overlay` is enabled. Escape or Skip closes the tour; Finish on the last step emits `finish` before closing. Page scrolling is unlocked by default; use `lock-scroll` to lock it. + +### Custom Content + +Replace the title, description, or footer with the `title`, default, and `actions` slots. Each slot receives the current step, progress, and navigation methods. + +```html + + + + +``` + +Button labels can be overridden with props, `Locale`, or `LocaleProvider`. Missing Tour messages in a scoped locale fall back to the global locale, then English. + +### Keyboard Access + +In the default overlay mode, focus moves to the card and Tab / Shift+Tab stay inside it. Closing or unmounting attempts to restore focus to the trigger. Escape affects only the topmost layer. +Focus is not trapped when target interaction is enabled or the overlay is disabled. Custom action slots should use buttons with clear text or accessible names. + +## API + +### Props + +| Prop | Description | Type | Default | +| --- | --- | --- | --- | +| `show` | Visibility; supports `v-model:show` | _boolean_ | `false` | +| `current` | Zero-based index; supports `v-model:current`; invalid values are clamped | _number_ | `0` | +| `steps` | Tour steps; an empty array hides the tour | _TourStep[]_ | `[]` | +| `placement` | Global card placement | _TourPlacement_ | `bottom` | +| `width` | Card width, limited by the viewport | _string \| number_ | `-` | +| `gap` | Highlight padding in px | _number_ | `8` | +| `gap-radius` | Highlight corner radius in px | _number_ | `4` | +| `overlay` | Show the mask | _boolean_ | `true` | +| `lock-scroll` | Lock page scrolling | _boolean_ | `false` | +| `scroll-into-view` | Scroll to offscreen targets on step changes | _boolean_ | `true` | +| `allow-target-interaction` | Allow interaction with the highlighted area | _boolean_ | `false` | +| `close-on-click-overlay` | Close on mask clicks | _boolean_ | `false` | +| `close-on-key-escape` | Close on Escape | _boolean_ | `true` | +| `show-close` | Show the Skip button | _boolean_ | `true` | +| `show-progress` | Show step progress | _boolean_ | `true` | +| `prev-button-text` | Previous button label | _string_ | `Previous` | +| `next-button-text` | Next button label | _string_ | `Next` | +| `finish-button-text` | Finish button label | _string_ | `Finish` | +| `close-button-text` | Skip button label | _string_ | `Skip` | +| `teleport` | Mount target; `false` disables teleporting. Keep the default to avoid transformed or clipping ancestors affecting positioning | _string \| Element \| false_ | `body` | + +### TourStep + +| Property | Description | Type | +| --- | --- | --- | +| `target` | Optional highlight target | _string \| HTMLElement \| (() => HTMLElement \| null \| undefined)_ | +| `title` | Step title | _string_ | +| `description` | Step description | _string_ | +| `placement` | Override the global placement | _TourPlacement_ | + +`TourPlacement` supports `top`, `bottom`, `left`, and `right`, with optional `-start` and `-end` suffixes, such as `bottom-start`. + +### Events + +| Event | Description | Arguments | +| --- | --- | --- | +| `open` | Tour becomes visible | `-` | +| `close` | Tour becomes hidden | `-` | +| `change` | Current step changes | `current: number, previous: number` | +| `finish` | Last-step Next button or `finish()` is used | `-` | +| `click-overlay` | Mask is clicked | `event: MouseEvent` | + +### Methods + +| Method | Description | Arguments | Return | +| --- | --- | --- | --- | +| `open` | Open at an index; defaults to the first step | `current?: number` | `-` | +| `close` | Close without emitting finish | `-` | `-` | +| `next` | Next step, or finish and close on the last step | `-` | `-` | +| `prev` | Previous step; no effect on the first step | `-` | `-` | +| `finish` | Finish and close; no effect while hidden | `-` | `-` | +| `resize` | Resolve the target again and update positions | `-` | `-` | + +### Slots + +| Name | Description | Arguments | +| --- | --- | --- | +| `default` | Step description | `TourSlotData` | +| `title` | Step title | `TourSlotData` | +| `actions` | Footer, replacing progress and action buttons | `TourSlotData` | + +`TourSlotData` contains `step: TourStep`, `current: number`, `total: number`, and the `next()`, `prev()`, `close()`, and `finish()` methods. + +### Style Variables + +| Variable | Default | +| --- | --- | +| `--tour-width` | `320px` | +| `--tour-padding` | `20px` | +| `--tour-border-radius` | `var(--dialog-border-radius)` | +| `--tour-background` | `var(--dialog-background)` | +| `--tour-title-color` | `var(--dialog-title-color)` | +| `--tour-title-font-size` | `var(--dialog-title-font-size)` | +| `--tour-description-color` | `var(--dialog-message-color)` | +| `--tour-description-font-size` | `var(--dialog-message-font-size)` | +| `--tour-description-line-height` | `var(--dialog-message-line-height)` | +| `--tour-overlay-color` | `var(--overlay-background-color)` | +| `--tour-highlight-color` | `var(--color-primary)` | diff --git a/packages/varlet-ui/src/tour/docs/zh-CN.md b/packages/varlet-ui/src/tour/docs/zh-CN.md new file mode 100644 index 00000000000..5432817a7c9 --- /dev/null +++ b/packages/varlet-ui/src/tour/docs/zh-CN.md @@ -0,0 +1,187 @@ +# 漫游引导 + +### 介绍 + +通过分步说明和目标高亮,引导用户认识页面中的主要功能。卡片复用 Dialog 的主题变量和 Button 的样式,支持 Material Design 2、Material Design 3 和深色主题。 + +### 基本使用 + +通过 `v-model:show` 控制显示,`steps` 配置各步骤。`target` 支持 CSS 选择器、HTML 元素和返回 HTML 元素的函数。通过 `v-model:current` 获取当前步骤,索引从 `0` 开始。 + +```html + + + +``` + +### 目标和定位 + +默认在目标下方显示卡片,空间不足时自动翻转,并保持卡片位于视口内。可以设置全局 `placement` 或每一步的 `placement`。 +组件会在切换步骤时将视口外的目标滚动到可见区域,设置 `:scroll-into-view="false"` 可以关闭自动滚动。页面和嵌套滚动容器的滚动、窗口或目标尺寸变化会更新定位。 + +不设置 `target`,或目标不存在、已隐藏、已移除、完全位于视口外时,卡片居中显示,不绘制高亮。这样可以配置欢迎页或结束页。选择器无效时也会使用居中卡片。 + +```html + + + +``` + +目标函数可以读取响应式元素 ref。对异步出现的选择器目标,或仅位置发生变化而没有滚动、尺寸变化的布局,请在 `nextTick()` 后调用 `resize()`。组件不会负责加载页面、切换路由或保存用户是否完成引导;这些行为由业务代码处理。 + +### 高亮和交互 + +`gap` 设置目标周围的留白,`gap-radius` 设置高亮圆角。默认遮罩阻止页面操作,高亮区域也不可点击。设置 `allow-target-interaction` 后,高亮目标可以操作;设置 `:overlay="false"` 可以保留卡片和高亮而隐藏遮罩。 + +```html + +``` + +默认点击遮罩不会关闭,设置 `close-on-click-overlay` 后允许关闭。按 `Escape` 或点击“跳过”关闭引导,最后一步点击“完成”会触发 `finish`,然后关闭。默认不锁定页面滚动,可通过 `lock-scroll` 开启。 + +### 自定义内容 + +使用 `title`、默认插槽和 `actions` 插槽替换标题、说明和页脚。插槽提供当前步骤、进度以及导航方法。 + +```html + + + + +``` + +按钮文案支持通过属性覆盖,也支持 `Locale` 和 `LocaleProvider`。自定义语言包没有 Tour 文案时,会使用全局语言包,再回退到英文。 + +### 键盘操作 + +默认遮罩模式会把焦点移到引导卡片,并将 `Tab` 和 `Shift+Tab` 限制在卡片内。关闭或卸载后,会尝试恢复触发元素的焦点。只响应最上层弹层的 `Escape`。 +启用目标交互或关闭遮罩后,不限制焦点在卡片内。自定义操作插槽应使用具有明确文字或无障碍名称的按钮。 + +## API + +### 属性 + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| `show` | 是否显示,支持 `v-model:show` | _boolean_ | `false` | +| `current` | 当前步骤索引,支持 `v-model:current`,越界值会修正到有效范围 | _number_ | `0` | +| `steps` | 引导步骤,空数组不显示引导 | _TourStep[]_ | `[]` | +| `placement` | 全局卡片位置 | _TourPlacement_ | `bottom` | +| `width` | 卡片宽度,仍受视口宽度限制 | _string \| number_ | `-` | +| `gap` | 高亮区域留白,单位 px | _number_ | `8` | +| `gap-radius` | 高亮区域圆角,单位 px | _number_ | `4` | +| `overlay` | 是否显示遮罩 | _boolean_ | `true` | +| `lock-scroll` | 是否锁定页面滚动 | _boolean_ | `false` | +| `scroll-into-view` | 切换步骤时自动滚动到目标 | _boolean_ | `true` | +| `allow-target-interaction` | 允许操作高亮区域 | _boolean_ | `false` | +| `close-on-click-overlay` | 点击遮罩时关闭 | _boolean_ | `false` | +| `close-on-key-escape` | 按 Escape 时关闭 | _boolean_ | `true` | +| `show-close` | 是否显示跳过按钮 | _boolean_ | `true` | +| `show-progress` | 是否显示步骤进度 | _boolean_ | `true` | +| `prev-button-text` | 上一步文案 | _string_ | `上一步` | +| `next-button-text` | 下一步文案 | _string_ | `下一步` | +| `finish-button-text` | 完成文案 | _string_ | `完成` | +| `close-button-text` | 跳过文案 | _string_ | `跳过` | +| `teleport` | 挂载位置,`false` 禁用传送;建议保留默认值以避免祖先变换和裁剪影响定位 | _string \| Element \| false_ | `body` | + +### TourStep + +| 参数 | 说明 | 类型 | +| --- | --- | --- | +| `target` | 可选高亮目标 | _string \| HTMLElement \| (() => HTMLElement \| null \| undefined)_ | +| `title` | 标题 | _string_ | +| `description` | 说明 | _string_ | +| `placement` | 覆盖全局卡片位置 | _TourPlacement_ | + +`TourPlacement` 支持 `top`、`bottom`、`left`、`right`,以及对应的 `-start` 和 `-end`,例如 `bottom-start`。 + +### 事件 + +| 事件名 | 说明 | 参数 | +| --- | --- | --- | +| `open` | 引导显示时触发 | `-` | +| `close` | 引导隐藏时触发 | `-` | +| `change` | 当前步骤变化时触发 | `current: number, previous: number` | +| `finish` | 点击最后一步的下一步按钮,或调用 `finish()` 时触发 | `-` | +| `click-overlay` | 点击遮罩时触发 | `event: MouseEvent` | + +### 方法 + +| 方法名 | 说明 | 参数 | 返回值 | +| --- | --- | --- | --- | +| `open` | 从指定步骤打开,默认从第一步开始 | `current?: number` | `-` | +| `close` | 关闭,不触发完成事件 | `-` | `-` | +| `next` | 下一步,最后一步则完成并关闭 | `-` | `-` | +| `prev` | 上一步,第一步不执行操作 | `-` | `-` | +| `finish` | 完成并关闭,隐藏时不执行操作 | `-` | `-` | +| `resize` | 重新解析目标并更新高亮、卡片定位 | `-` | `-` | + +### 插槽 + +| 名称 | 说明 | 参数 | +| --- | --- | --- | +| `default` | 步骤说明 | `TourSlotData` | +| `title` | 步骤标题 | `TourSlotData` | +| `actions` | 页脚,替换进度和操作按钮 | `TourSlotData` | + +`TourSlotData` 包含 `step: TourStep`、`current: number`、`total: number`,以及 `next()`、`prev()`、`close()` 和 `finish()` 方法。 + +### 样式变量 + +| 变量名 | 默认值 | +| --- | --- | +| `--tour-width` | `320px` | +| `--tour-padding` | `20px` | +| `--tour-border-radius` | `var(--dialog-border-radius)` | +| `--tour-background` | `var(--dialog-background)` | +| `--tour-title-color` | `var(--dialog-title-color)` | +| `--tour-title-font-size` | `var(--dialog-title-font-size)` | +| `--tour-description-color` | `var(--dialog-message-color)` | +| `--tour-description-font-size` | `var(--dialog-message-font-size)` | +| `--tour-description-line-height` | `var(--dialog-message-line-height)` | +| `--tour-overlay-color` | `var(--overlay-background-color)` | +| `--tour-highlight-color` | `var(--color-primary)` | diff --git a/packages/varlet-ui/src/tour/example/index.vue b/packages/varlet-ui/src/tour/example/index.vue new file mode 100644 index 00000000000..3cd7f42cea7 --- /dev/null +++ b/packages/varlet-ui/src/tour/example/index.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/packages/varlet-ui/src/tour/example/locale/en-US.ts b/packages/varlet-ui/src/tour/example/locale/en-US.ts new file mode 100644 index 00000000000..55f97b053ae --- /dev/null +++ b/packages/varlet-ui/src/tour/example/locale/en-US.ts @@ -0,0 +1,25 @@ +export default { + basicUse: 'Basic Usage', + start: 'Start Tour', + workspace: 'My Workspace', + create: 'New Project', + search: 'Search projects', + project: 'Website Redesign', + projectDescription: 'Updated today · 3 members', + active: 'Active', + createTitle: 'Create a project', + createDescription: 'Start here to create a project and invite your teammates.', + searchTitle: 'Find your work', + searchDescription: 'Search by project name to quickly find what you need.', + overviewTitle: 'Check project progress', + overviewDescription: 'View the latest updates and project status here.', + customContent: 'Custom Content and Actions', + customStart: 'Show Custom Tour', + welcomeTitle: 'Welcome to your workspace', + welcomeDescription: 'Take a quick tour to learn where to find your projects.', + skip: 'Skip', + next: 'Next', + finish: 'Get Started', + methods: 'Instance Methods', + startFromSearch: 'Start from the Search Step', +} diff --git a/packages/varlet-ui/src/tour/example/locale/index.ts b/packages/varlet-ui/src/tour/example/locale/index.ts new file mode 100644 index 00000000000..a80af0c735b --- /dev/null +++ b/packages/varlet-ui/src/tour/example/locale/index.ts @@ -0,0 +1,16 @@ +import { Locale } from '@varlet/ui' +import enUS from './en-US' +import zhCN from './zh-CN' + +const { add, use: exampleUse, t, merge } = Locale.useLocale() +const use = (lang: string) => { + Locale.use(lang) + exampleUse(lang) +} + +Locale.add('zh-CN', Locale.zhCN) +Locale.add('en-US', Locale.enUS) +add('zh-CN', zhCN) +add('en-US', enUS) + +export { add, t, merge, use } diff --git a/packages/varlet-ui/src/tour/example/locale/zh-CN.ts b/packages/varlet-ui/src/tour/example/locale/zh-CN.ts new file mode 100644 index 00000000000..376ac8a20fb --- /dev/null +++ b/packages/varlet-ui/src/tour/example/locale/zh-CN.ts @@ -0,0 +1,25 @@ +export default { + basicUse: '基本使用', + start: '开始引导', + workspace: '我的工作台', + create: '新建项目', + search: '搜索项目', + project: '网站改版', + projectDescription: '今天更新 · 3 位成员', + active: '进行中', + createTitle: '创建项目', + createDescription: '从这里新建项目,邀请同事一起协作。', + searchTitle: '查找项目', + searchDescription: '输入项目名称,快速找到你需要的内容。', + overviewTitle: '查看项目进度', + overviewDescription: '这里会显示项目的最新动态和当前状态。', + customContent: '自定义内容和操作', + customStart: '查看自定义引导', + welcomeTitle: '欢迎来到工作台', + welcomeDescription: '用两步了解工作台,找到你的项目。', + skip: '跳过', + next: '下一步', + finish: '开始使用', + methods: '实例方法', + startFromSearch: '从搜索步骤开始', +} diff --git a/packages/varlet-ui/src/tour/index.ts b/packages/varlet-ui/src/tour/index.ts new file mode 100644 index 00000000000..beebf197b44 --- /dev/null +++ b/packages/varlet-ui/src/tour/index.ts @@ -0,0 +1,11 @@ +import { withInstall, withPropsDefaultsSetter } from '../utils/components' +import { props as tourProps } from './props' +import Tour from './Tour.vue' + +withInstall(Tour) +withPropsDefaultsSetter(Tour, tourProps) + +export { tourProps } +export type { TourPlacement, TourStep, TourTarget } from './props' +export const _TourComponent = Tour +export default Tour diff --git a/packages/varlet-ui/src/tour/props.ts b/packages/varlet-ui/src/tour/props.ts new file mode 100644 index 00000000000..f11a2385cc5 --- /dev/null +++ b/packages/varlet-ui/src/tour/props.ts @@ -0,0 +1,46 @@ +import { type PropType, type TeleportProps } from 'vue' +import { type NeededPopperPlacement } from '../menu/usePopover' +import { defineListenerProp } from '../utils/components' + +export type TourPlacement = NeededPopperPlacement +export type TourTarget = string | HTMLElement | (() => HTMLElement | null | undefined) + +export interface TourStep { + target?: TourTarget + title?: string + description?: string + placement?: TourPlacement +} + +export const props = { + show: Boolean, + current: { type: Number, default: 0 }, + steps: { type: Array as PropType, default: () => [] }, + placement: { type: String as PropType, default: 'bottom' }, + width: [String, Number], + gap: { type: Number, default: 8 }, + gapRadius: { type: Number, default: 4 }, + overlay: { type: Boolean, default: true }, + lockScroll: Boolean, + scrollIntoView: { type: Boolean, default: true }, + allowTargetInteraction: Boolean, + closeOnClickOverlay: Boolean, + closeOnKeyEscape: { type: Boolean, default: true }, + showClose: { type: Boolean, default: true }, + showProgress: { type: Boolean, default: true }, + prevButtonText: String, + nextButtonText: String, + finishButtonText: String, + closeButtonText: String, + teleport: { + type: [String, Object, Boolean] as PropType, + default: 'body', + }, + onOpen: defineListenerProp<() => void>(), + onClose: defineListenerProp<() => void>(), + onFinish: defineListenerProp<() => void>(), + onChange: defineListenerProp<(current: number, previous: number) => void>(), + onClickOverlay: defineListenerProp<(event: MouseEvent) => void>(), + 'onUpdate:show': defineListenerProp<(show: boolean) => void>(), + 'onUpdate:current': defineListenerProp<(current: number) => void>(), +} diff --git a/packages/varlet-ui/src/tour/tour.less b/packages/varlet-ui/src/tour/tour.less new file mode 100644 index 00000000000..91e3a3cb5de --- /dev/null +++ b/packages/varlet-ui/src/tour/tour.less @@ -0,0 +1,111 @@ +:root { + --tour-width: 320px; + --tour-padding: 20px; + --tour-border-radius: var(--dialog-border-radius); + --tour-background: var(--dialog-background); + --tour-title-color: var(--dialog-title-color); + --tour-title-font-size: var(--dialog-title-font-size); + --tour-description-color: var(--dialog-message-color); + --tour-description-font-size: var(--dialog-message-font-size); + --tour-description-line-height: var(--dialog-message-line-height); + --tour-overlay-color: var(--overlay-background-color); + --tour-highlight-color: var(--color-primary); +} + +.var-tour { + position: fixed; + inset: 0; + pointer-events: none; + + &__mask { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + fill: var(--tour-overlay-color); + + path { + pointer-events: fill; + } + } + + &__highlight { + position: absolute; + box-shadow: 0 0 0 2px var(--tour-highlight-color); + pointer-events: auto; + } + + &--interactive { + pointer-events: none; + } + + &__panel { + position: fixed; + width: var(--tour-width); + max-width: calc(100vw - 24px); + max-height: calc(100vh - 24px); + overflow: auto; + padding: var(--tour-padding); + border-radius: var(--tour-border-radius); + background: var(--tour-background); + pointer-events: auto; + outline: none; + } + + &--center { + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + } + + &__title { + color: var(--tour-title-color); + font-size: var(--tour-title-font-size); + margin-bottom: 12px; + overflow-wrap: anywhere; + } + + &__description { + color: var(--tour-description-color); + font-size: var(--tour-description-font-size); + line-height: var(--tour-description-line-height); + overflow-wrap: anywhere; + } + + &__footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 20px; + } + + &__progress { + color: var(--tour-description-color); + font-size: var(--font-size-sm); + } + + &__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-inline-start: auto; + } + + &--fade-enter-from, + &--fade-leave-to { + opacity: 0; + } + + &--fade-enter-active, + &--fade-leave-active { + transition: opacity 0.2s; + } +} + +@media (prefers-reduced-motion: reduce) { + .var-tour--fade-enter-active, + .var-tour--fade-leave-active { + transition: none; + } +} diff --git a/packages/varlet-ui/types/index.d.ts b/packages/varlet-ui/types/index.d.ts index 30a427f4a1a..3f1e97ad9a3 100644 --- a/packages/varlet-ui/types/index.d.ts +++ b/packages/varlet-ui/types/index.d.ts @@ -103,6 +103,7 @@ export * from './tabsItems' export * from './themes' export * from './timePicker' export * from './tooltip' +export * from './tour' export * from './treeMenu' export * from './uploader' export * from './varComponent' @@ -208,6 +209,7 @@ declare module 'vue' { VarThemes: typeof import('@varlet/ui')['_ThemesComponent'] VarTimePicker: typeof import('@varlet/ui')['_TimePickerComponent'] VarTooltip: typeof import('@varlet/ui')['_TooltipComponent'] + VarTour: typeof import('@varlet/ui')['_TourComponent'] VarTreeMenu: typeof import('@varlet/ui')['_TreeMenuComponent'] VarUploader: typeof import('@varlet/ui')['_UploaderComponent'] VarWatermark: typeof import('@varlet/ui')['_WatermarkComponent'] diff --git a/packages/varlet-ui/types/locale.d.ts b/packages/varlet-ui/types/locale.d.ts index 2b4df8af76e..26fe1be2b52 100644 --- a/packages/varlet-ui/types/locale.d.ts +++ b/packages/varlet-ui/types/locale.d.ts @@ -5,6 +5,12 @@ export type Month = '01' | '02' | '03' | '04' | '05' | '06' | '07' | '08' | '09' export type Week = '0' | '1' | '2' | '3' | '4' | '5' | '6' export type Message = { + // Tour + tourTitle?: string + tourPrevButtonText?: string + tourNextButtonText?: string + tourFinishButtonText?: string + tourCloseButtonText?: string // Dialog dialogTitle: string dialogConfirmButtonText: string diff --git a/packages/varlet-ui/types/styleVars.d.ts b/packages/varlet-ui/types/styleVars.d.ts index 3a33bab46f7..9cc5830d8a6 100644 --- a/packages/varlet-ui/types/styleVars.d.ts +++ b/packages/varlet-ui/types/styleVars.d.ts @@ -1016,6 +1016,17 @@ interface BaseStyleVars { '--tooltip-success-text-color'?: string '--tooltip-warning-text-color'?: string '--tooltip-danger-text-color'?: string + '--tour-width'?: string + '--tour-padding'?: string + '--tour-border-radius'?: string + '--tour-background'?: string + '--tour-title-color'?: string + '--tour-title-font-size'?: string + '--tour-description-color'?: string + '--tour-description-font-size'?: string + '--tour-description-line-height'?: string + '--tour-overlay-color'?: string + '--tour-highlight-color'?: string '--tree-menu-background'?: string '--tree-menu-border-color'?: string '--tree-menu-item-text-color'?: string diff --git a/packages/varlet-ui/types/tour.d.ts b/packages/varlet-ui/types/tour.d.ts new file mode 100644 index 00000000000..0d4286bfef4 --- /dev/null +++ b/packages/varlet-ui/types/tour.d.ts @@ -0,0 +1,73 @@ +import { Placement } from '@popperjs/core' +import { TeleportProps, VNode } from 'vue' +import { BasicAttributes, ListenerProp, SetPropsDefaults, VarComponent } from './varComponent' + +export type TourPlacement = Exclude +export type TourTarget = string | HTMLElement | (() => HTMLElement | null | undefined) + +export interface TourStep { + target?: TourTarget + title?: string + description?: string + placement?: TourPlacement +} + +export interface TourProps extends BasicAttributes { + show?: boolean + current?: number + steps?: TourStep[] + placement?: TourPlacement + width?: string | number + gap?: number + gapRadius?: number + overlay?: boolean + lockScroll?: boolean + scrollIntoView?: boolean + allowTargetInteraction?: boolean + closeOnClickOverlay?: boolean + closeOnKeyEscape?: boolean + showClose?: boolean + showProgress?: boolean + prevButtonText?: string + nextButtonText?: string + finishButtonText?: string + closeButtonText?: string + teleport?: TeleportProps['to'] | false + onOpen?: ListenerProp<() => void> + onClose?: ListenerProp<() => void> + onFinish?: ListenerProp<() => void> + onChange?: ListenerProp<(current: number, previous: number) => void> + onClickOverlay?: ListenerProp<(event: MouseEvent) => void> + 'onUpdate:show'?: ListenerProp<(show: boolean) => void> + 'onUpdate:current'?: ListenerProp<(current: number) => void> +} + +export interface TourSlotData { + step: TourStep + current: number + total: number + next(): void + prev(): void + close(): void + finish(): void +} + +export declare const tourProps: Record + +export class Tour extends VarComponent { + static setPropsDefaults: SetPropsDefaults + $props: TourProps + $slots: { + default(data: TourSlotData): VNode[] + title(data: TourSlotData): VNode[] + actions(data: TourSlotData): VNode[] + } + open(current?: number): void + close(): void + next(): void + prev(): void + finish(): void + resize(): void +} + +export class _TourComponent extends Tour {} diff --git a/packages/varlet-ui/varlet.config.mjs b/packages/varlet-ui/varlet.config.mjs index c94d458f954..0668bab16d8 100644 --- a/packages/varlet-ui/varlet.config.mjs +++ b/packages/varlet-ui/varlet.config.mjs @@ -471,6 +471,14 @@ export default defineConfig({ doc: 'rail-navigation', type: 2, }, + { + text: { + 'zh-CN': 'Tour 漫游引导', + 'en-US': 'Tour', + }, + doc: 'tour', + type: 2, + }, { text: { 'zh-CN': 'TreeMenu 树形菜单', diff --git a/packages/varlet-vscode-extension/src/componentsMap.ts b/packages/varlet-vscode-extension/src/componentsMap.ts index 375aa90d748..c75a401005e 100644 --- a/packages/varlet-vscode-extension/src/componentsMap.ts +++ b/packages/varlet-vscode-extension/src/componentsMap.ts @@ -301,6 +301,9 @@ export const componentsMap: Record = { tooltip: { path: '/tooltip', }, + tour: { + path: '/tour', + }, paper: { path: '/paper', },