diff --git a/packages/varlet-cli/lib/node/bin.js b/packages/varlet-cli/lib/node/bin.js old mode 100644 new mode 100755 diff --git a/packages/varlet-ui/src/locale/en-US.ts b/packages/varlet-ui/src/locale/en-US.ts index 424299afd11..22f4ea1d266 100644 --- a/packages/varlet-ui/src/locale/en-US.ts +++ b/packages/varlet-ui/src/locale/en-US.ts @@ -104,4 +104,8 @@ export default { paginationJump: 'Go to', // time-picker timePickerHint: 'SELECT TIME', + // tour + tourPrevious: 'previous', + tourNext: 'next', + tourFinish: 'finish', } satisfies Message diff --git a/packages/varlet-ui/src/locale/zh-CN.ts b/packages/varlet-ui/src/locale/zh-CN.ts index 1c9b7986971..eaaf3c07734 100644 --- a/packages/varlet-ui/src/locale/zh-CN.ts +++ b/packages/varlet-ui/src/locale/zh-CN.ts @@ -104,4 +104,8 @@ export default { paginationJump: '前往', // time-picker timePickerHint: '选择时间', + // tour + tourPrevious: '上一步', + tourNext: '下一步', + tourFinish: '结束引导', } satisfies Message diff --git a/packages/varlet-ui/src/locale/zh-TW.ts b/packages/varlet-ui/src/locale/zh-TW.ts index fba9bf1fddd..689df539abc 100644 --- a/packages/varlet-ui/src/locale/zh-TW.ts +++ b/packages/varlet-ui/src/locale/zh-TW.ts @@ -104,4 +104,8 @@ export default { paginationJump: '前往', // time-picker timePickerHint: '選擇時間', + // tour + tourPrevious: '上一步', + tourNext: '下一步', + tourFinish: '結束引導', } satisfies Message diff --git a/packages/varlet-ui/src/themes/__tests__/__snapshots__/index.spec.js.snap b/packages/varlet-ui/src/themes/__tests__/__snapshots__/index.spec.js.snap index 31c9004045a..affde725b8b 100644 --- a/packages/varlet-ui/src/themes/__tests__/__snapshots__/index.spec.js.snap +++ b/packages/varlet-ui/src/themes/__tests__/__snapshots__/index.spec.js.snap @@ -861,6 +861,29 @@ exports[`dark theme 1`] = ` "--tooltip-success-text-color": "var(--color-on-success)", "--tooltip-warning-color": "var(--color-warning)", "--tooltip-warning-text-color": "var(--color-on-warning)", + "--tour-actions-padding": "0 12px 12px 20px", + "--tour-background": "var(--color-surface-container-low)", + "--tour-border-radius": "3px", + "--tour-close-button-right": "20px", + "--tour-close-button-top": "20px", + "--tour-indicator-background-color": "var(--color-surface-container-highest)", + "--tour-message-color": "#bbb", + "--tour-message-font-size": "var(--font-size-md)", + "--tour-message-line-height": "24px", + "--tour-message-padding": "12px 20px", + "--tour-next-button-color": "var(--color-primary)", + "--tour-prev-button-color": "var(--color-primary)", + "--tour-primary-background": "var(--color-primary)", + "--tour-primary-indicator-active-background-color": "var(--color-on-primary)", + "--tour-primary-indicator-background-color": "rgba(255, 255, 255, 0.15)", + "--tour-primary-message-color": "var(--color-on-info)", + "--tour-primary-next-button-color": "var(--color-on-primary)", + "--tour-primary-prev-button-color": "var(--color-on-primary)", + "--tour-primary-title-color": "var(--color-on-primary)", + "--tour-title-color": "#fff", + "--tour-title-font-size": "var(--font-size-lg)", + "--tour-title-padding": "20px 20px 0", + "--tour-width": "280px", "--uploader-action-background": "#303030", "--uploader-action-icon-color": "#fff", "--uploader-action-icon-size": "24px", @@ -1759,6 +1782,29 @@ exports[`md3Dark theme 1`] = ` "--tooltip-success-text-color": "var(--color-on-success)", "--tooltip-warning-color": "var(--color-warning)", "--tooltip-warning-text-color": "var(--color-on-warning)", + "--tour-actions-padding": "0 24px 24px", + "--tour-background": "var(--color-surface-container-high)", + "--tour-border-radius": "28px", + "--tour-close-button-right": "24px", + "--tour-close-button-top": "28px", + "--tour-indicator-background-color": "var(--color-surface-container-low)", + "--tour-message-color": "var(--color-on-surface-variant)", + "--tour-message-font-size": "var(--font-size-md)", + "--tour-message-line-height": "24px", + "--tour-message-padding": "16px 24px 24px", + "--tour-next-button-color": "var(--color-primary)", + "--tour-prev-button-color": "var(--color-primary)", + "--tour-primary-background": "var(--color-primary)", + "--tour-primary-indicator-active-background-color": "var(--color-on-primary)", + "--tour-primary-indicator-background-color": "rgba(0, 0, 0, 0.15)", + "--tour-primary-message-color": "var(--color-on-info)", + "--tour-primary-next-button-color": "var(--color-on-primary)", + "--tour-primary-prev-button-color": "var(--color-on-primary)", + "--tour-primary-title-color": "var(--color-on-primary)", + "--tour-title-color": "var(--color-inverse-surface)", + "--tour-title-font-size": "20px", + "--tour-title-padding": "24px 24px 0", + "--tour-width": "312px", "--uploader-action-background": "var(--color-surface-container-highest)", "--uploader-action-icon-color": "var(--color-on-surface-variant)", "--uploader-action-icon-size": "24px", @@ -2639,6 +2685,29 @@ exports[`md3Light theme 1`] = ` "--tooltip-success-text-color": "var(--color-on-success)", "--tooltip-warning-color": "var(--color-warning)", "--tooltip-warning-text-color": "var(--color-on-warning)", + "--tour-actions-padding": "0 24px 24px", + "--tour-background": "var(--color-surface-container-high)", + "--tour-border-radius": "28px", + "--tour-close-button-right": "24px", + "--tour-close-button-top": "28px", + "--tour-indicator-background-color": "var(--color-surface-container-low)", + "--tour-message-color": "var(--color-on-surface-variant)", + "--tour-message-font-size": "var(--font-size-md)", + "--tour-message-line-height": "24px", + "--tour-message-padding": "16px 24px 24px", + "--tour-next-button-color": "var(--color-primary)", + "--tour-prev-button-color": "var(--color-primary)", + "--tour-primary-background": "var(--color-primary)", + "--tour-primary-indicator-active-background-color": "var(--color-on-primary)", + "--tour-primary-indicator-background-color": "rgba(255, 255, 255, 0.15)", + "--tour-primary-message-color": "var(--color-on-info)", + "--tour-primary-next-button-color": "var(--color-on-primary)", + "--tour-primary-prev-button-color": "var(--color-on-primary)", + "--tour-primary-title-color": "var(--color-on-primary)", + "--tour-title-color": "#1D1B20", + "--tour-title-font-size": "20px", + "--tour-title-padding": "24px 24px 0", + "--tour-width": "312px", "--uploader-action-background": "var(--color-surface-container-low)", "--uploader-action-icon-color": "var(--color-on-surface-variant)", "--uploader-action-icon-size": "24px", diff --git a/packages/varlet-ui/src/themes/dark/index.ts b/packages/varlet-ui/src/themes/dark/index.ts index 8ee8bbe074d..1e920c716f3 100644 --- a/packages/varlet-ui/src/themes/dark/index.ts +++ b/packages/varlet-ui/src/themes/dark/index.ts @@ -63,6 +63,7 @@ import table from './table' import tabs from './tabs' import timePicker from './timePicker' import tooltip from './tooltip' +import tour from './tour' import uploader from './uploader' import watermark from './watermark' @@ -211,4 +212,5 @@ export default { ...select, ...code, ...signature, + ...tour, } as StyleVars diff --git a/packages/varlet-ui/src/themes/dark/tour.ts b/packages/varlet-ui/src/themes/dark/tour.ts new file mode 100644 index 00000000000..2feb1372bb3 --- /dev/null +++ b/packages/varlet-ui/src/themes/dark/tour.ts @@ -0,0 +1,25 @@ +export default { + '--tour-width': '280px', + '--tour-background': 'var(--color-surface-container-low)', + '--tour-border-radius': '3px', + '--tour-title-padding': '20px 20px 0', + '--tour-title-color': '#fff', + '--tour-title-font-size': 'var(--font-size-lg)', + '--tour-message-color': '#bbb', + '--tour-message-padding': '12px 20px', + '--tour-message-font-size': 'var(--font-size-md)', + '--tour-message-line-height': '24px', + '--tour-indicator-background-color': 'var(--color-surface-container-highest)', + '--tour-actions-padding': '0 12px 12px 20px', + '--tour-next-button-color': 'var(--color-primary)', + '--tour-prev-button-color': 'var(--color-primary)', + '--tour-close-button-right': '20px', + '--tour-close-button-top': '20px', + '--tour-primary-background': 'var(--color-primary)', + '--tour-primary-title-color': 'var(--color-on-primary)', + '--tour-primary-message-color': 'var(--color-on-info)', + '--tour-primary-indicator-background-color': 'rgba(255, 255, 255, 0.15)', + '--tour-primary-indicator-active-background-color': 'var(--color-on-primary)', + '--tour-primary-next-button-color': 'var(--color-on-primary)', + '--tour-primary-prev-button-color': 'var(--color-on-primary)', +} diff --git a/packages/varlet-ui/src/themes/md3-dark/index.ts b/packages/varlet-ui/src/themes/md3-dark/index.ts index afdcc3d67e5..011a998bc0b 100644 --- a/packages/varlet-ui/src/themes/md3-dark/index.ts +++ b/packages/varlet-ui/src/themes/md3-dark/index.ts @@ -63,6 +63,7 @@ import table from './table' import tabs from './tabs' import timePicker from './timePicker' import tooltip from './tooltip' +import tour from './tour' import uploader from './uploader' import watermark from './watermark' @@ -211,4 +212,5 @@ export default { ...swipe, ...code, ...signature, + ...tour, } as StyleVars diff --git a/packages/varlet-ui/src/themes/md3-dark/tour.ts b/packages/varlet-ui/src/themes/md3-dark/tour.ts new file mode 100644 index 00000000000..5b637615499 --- /dev/null +++ b/packages/varlet-ui/src/themes/md3-dark/tour.ts @@ -0,0 +1,25 @@ +export default { + '--tour-width': '312px', + '--tour-background': 'var(--color-surface-container-high)', + '--tour-border-radius': '28px', + '--tour-title-padding': '24px 24px 0', + '--tour-title-color': 'var(--color-inverse-surface)', + '--tour-title-font-size': '20px', + '--tour-message-color': 'var(--color-on-surface-variant)', + '--tour-message-padding': '16px 24px 24px', + '--tour-message-font-size': 'var(--font-size-md)', + '--tour-message-line-height': '24px', + '--tour-indicator-background-color': 'var(--color-surface-container-low)', + '--tour-actions-padding': '0 24px 24px', + '--tour-next-button-color': 'var(--color-primary)', + '--tour-prev-button-color': 'var(--color-primary)', + '--tour-close-button-right': '24px', + '--tour-close-button-top': '28px', + '--tour-primary-background': 'var(--color-primary)', + '--tour-primary-title-color': 'var(--color-on-primary)', + '--tour-primary-message-color': 'var(--color-on-info)', + '--tour-primary-indicator-background-color': 'rgba(0, 0, 0, 0.15)', + '--tour-primary-indicator-active-background-color': 'var(--color-on-primary)', + '--tour-primary-next-button-color': 'var(--color-on-primary)', + '--tour-primary-prev-button-color': 'var(--color-on-primary)', +} diff --git a/packages/varlet-ui/src/themes/md3-light/index.ts b/packages/varlet-ui/src/themes/md3-light/index.ts index 9641c039b20..4952d53305f 100644 --- a/packages/varlet-ui/src/themes/md3-light/index.ts +++ b/packages/varlet-ui/src/themes/md3-light/index.ts @@ -63,6 +63,7 @@ import table from './table' import tabs from './tabs' import timePicker from './timePicker' import tooltip from './tooltip' +import tour from './tour' import uploader from './uploader' import watermark from './watermark' @@ -211,4 +212,5 @@ export default { ...appBar, ...code, ...signature, + ...tour, } as StyleVars diff --git a/packages/varlet-ui/src/themes/md3-light/tour.ts b/packages/varlet-ui/src/themes/md3-light/tour.ts new file mode 100644 index 00000000000..593c84c9fdf --- /dev/null +++ b/packages/varlet-ui/src/themes/md3-light/tour.ts @@ -0,0 +1,25 @@ +export default { + '--tour-width': '312px', + '--tour-background': 'var(--color-surface-container-high)', + '--tour-border-radius': '28px', + '--tour-title-padding': '24px 24px 0', + '--tour-title-color': '#1D1B20', + '--tour-title-font-size': '20px', + '--tour-message-color': 'var(--color-on-surface-variant)', + '--tour-message-padding': '16px 24px 24px', + '--tour-message-font-size': 'var(--font-size-md)', + '--tour-message-line-height': '24px', + '--tour-indicator-background-color': 'var(--color-surface-container-low)', + '--tour-actions-padding': '0 24px 24px', + '--tour-next-button-color': 'var(--color-primary)', + '--tour-prev-button-color': 'var(--color-primary)', + '--tour-close-button-right': '24px', + '--tour-close-button-top': '28px', + '--tour-primary-background': 'var(--color-primary)', + '--tour-primary-title-color': 'var(--color-on-primary)', + '--tour-primary-message-color': 'var(--color-on-info)', + '--tour-primary-indicator-background-color': 'rgba(255, 255, 255, 0.15)', + '--tour-primary-indicator-active-background-color': 'var(--color-on-primary)', + '--tour-primary-next-button-color': 'var(--color-on-primary)', + '--tour-primary-prev-button-color': 'var(--color-on-primary)', +} diff --git a/packages/varlet-ui/src/tour-step/TourStep.vue b/packages/varlet-ui/src/tour-step/TourStep.vue new file mode 100644 index 00000000000..28892c11ba7 --- /dev/null +++ b/packages/varlet-ui/src/tour-step/TourStep.vue @@ -0,0 +1,41 @@ + + + + {{ title }} + + + {{ message }} + + + + + + + diff --git a/packages/varlet-ui/src/tour-step/index.ts b/packages/varlet-ui/src/tour-step/index.ts new file mode 100644 index 00000000000..bc07ea568c4 --- /dev/null +++ b/packages/varlet-ui/src/tour-step/index.ts @@ -0,0 +1,12 @@ +import { withInstall, withPropsDefaultsSetter } from '../utils/components' +import { props as tourStepProps } from './props' +import TourStep from './TourStep.vue' + +withInstall(TourStep) +withPropsDefaultsSetter(TourStep, tourStepProps) + +export { tourStepProps } + +export const _TourStepComponent = TourStep + +export default TourStep diff --git a/packages/varlet-ui/src/tour-step/props.ts b/packages/varlet-ui/src/tour-step/props.ts new file mode 100644 index 00000000000..5743738a1d3 --- /dev/null +++ b/packages/varlet-ui/src/tour-step/props.ts @@ -0,0 +1,33 @@ +import { type PropType } from 'vue' +import { popupProps } from '../popup' +import { Placement } from '../tour/props' +import { pickProps } from '../utils/components' + +export const props = { + target: [String, Object] as PropType, + title: String, + message: String, + placement: String as PropType, + width: [Number, String], + overlay: { + type: Boolean, + default: undefined, + }, + arrow: { + type: Boolean, + default: undefined, + }, + closeable: { + type: Boolean, + default: undefined, + }, + prevButtonText: String, + nextButtonText: String, + prevButtonTextColor: String, + nextButtonTextColor: String, + prevButtonColor: String, + nextButtonColor: String, + contentClass: String, + contentStyle: Object, + ...pickProps(popupProps, ['overlayClass', 'overlayStyle']), +} diff --git a/packages/varlet-ui/src/tour-step/provide.ts b/packages/varlet-ui/src/tour-step/provide.ts new file mode 100644 index 00000000000..5814f593d8e --- /dev/null +++ b/packages/varlet-ui/src/tour-step/provide.ts @@ -0,0 +1,15 @@ +import { ExtractPropTypes } from 'vue' +import { useParent } from '@varlet/use' +import { TOUR_BIND_STEP_KEY, TourProvider, TourStepProps } from '../tour/provide' + +export interface TourStepProvider extends ExtractPropTypes {} + +export function useTour() { + const { bindParent, parentProvider, index } = useParent(TOUR_BIND_STEP_KEY) + + return { + index, + tour: parentProvider, + bindTour: bindParent, + } +} diff --git a/packages/varlet-ui/src/tour-step/tourStep.less b/packages/varlet-ui/src/tour-step/tourStep.less new file mode 100644 index 00000000000..cd599321ce0 --- /dev/null +++ b/packages/varlet-ui/src/tour-step/tourStep.less @@ -0,0 +1,14 @@ +.var-tour-step { + &__title { + padding: var(--tour-title-padding); + color: var(--tour-title-color); + font-size: var(--tour-title-font-size); + } + + &__message { + padding: var(--tour-message-padding); + color: var(--tour-message-color); + font-size: var(--tour-message-font-size); + line-height: var(--tour-message-line-height); + } +} diff --git a/packages/varlet-ui/src/tour/Tour.vue b/packages/varlet-ui/src/tour/Tour.vue new file mode 100644 index 00000000000..a91e374b6c6 --- /dev/null +++ b/packages/varlet-ui/src/tour/Tour.vue @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + {{ stepProps?.prevButtonText ?? (pt ? pt : t)('tourPrevious') }} + + + {{ stepProps?.nextButtonText ?? (pt ? pt : t)(isLastStep ? 'tourFinish' : 'tourNext') }} + + + + + + + + + + + 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..cf2b616ddae --- /dev/null +++ b/packages/varlet-ui/src/tour/__tests__/index.spec.js @@ -0,0 +1,420 @@ +import { createApp, nextTick } from 'vue' +import { mount } from '@vue/test-utils' +import { describe, expect, test, vi } from 'vitest' +import Tour from '..' +import TourStep from '../../tour-step' +import VarTourStep from '../../tour-step/TourStep' +import { delay, triggerKeyboard } from '../../utils/test' +import VarTour from '../Tour' + +const Wrapper = { + components: { + [VarTour.name]: VarTour, + [VarTourStep.name]: VarTourStep, + }, + props: [ + 'closeable', + 'overlay', + 'type', + 'closeOnKeyEscape', + 'closeOnClickOverlay', + 'onClose', + 'onFinish', + 'onChange', + 'onKeyEscape', + 'onClickOverlay', + ], + data: () => ({ + btnRef: null, + show: true, + current: 0, + }), + template: ` + + cover + + + + + + + + + + `, +} + +test('tour plugin', () => { + const app = createApp({}).use(Tour) + expect(app.component(Tour.name)).toBeTruthy() +}) + +test('tour step plugin', () => { + const app = createApp({}).use(TourStep) + expect(app.component(TourStep.name)).toBeTruthy() +}) + +test('tour basic', async () => { + const wrapper = mount(Wrapper) + + await delay(150) + + expect(wrapper.find('.var-tour-step__title').text()).toEqual('first') + expect(wrapper.find('.var-tour-step__message').text()).toEqual('cover message') + wrapper.unmount() +}) + +test('tour non modal', async () => { + const wrapper = mount(Wrapper, { + props: { + overlay: false, + }, + }) + + await delay(50) + expect(wrapper.find('.var-tour__overlay').exists()).toBeFalsy() + wrapper.unmount() +}) + +test('tour custom indicators', async () => { + const wrapper = mount(Wrapper, { + slots: { + indicators: ({ current, total }) => `${current + 1} / ${total}`, + }, + }) + + await delay(50) + expect(wrapper.find('.var-tour__indicators').text()).toEqual('1 / 2') + wrapper.unmount() +}) + +test('tour type primary', async () => { + const wrapper = mount(Wrapper, { + props: { + type: 'primary', + }, + }) + + await delay(50) + expect(wrapper.find('.var-tour--primary').exists()).toBeTruthy() + wrapper.unmount() +}) + +test('tour no target', async () => { + const wrapper = mount({ + components: { + [VarTour.name]: VarTour, + [VarTourStep.name]: VarTourStep, + }, + template: ` + + + + `, + }) + + await delay(50) + const style = wrapper.find('.var-tour__content').attributes('style') + expect(style).toContain('position: fixed') + expect(style).toContain('top: 50%') + expect(style).toContain('left: 50%') + expect(style).toContain('transform: translate(-50%, -50%)') + wrapper.unmount() +}) + +test('tour previous and next and finish', async () => { + const wrapper = mount(Wrapper) + + await delay(50) + + expect(wrapper.find('.var-tour-step__title').text()).toEqual('first') + wrapper.find('.var-tour__next-button').trigger('click') + + await nextTick() + + expect(wrapper.find('.var-tour-step__title').text()).toEqual('second') + wrapper.find('.var-tour__previous-button').trigger('click') + + await nextTick() + + expect(wrapper.find('.var-tour-step__title').text()).toEqual('first') + wrapper.find('.var-tour__next-button').trigger('click') + + await nextTick() + + wrapper.find('.var-tour__next-button').trigger('click') + + await nextTick() + + expect(wrapper.vm.show).toBe(false) + + wrapper.unmount() +}) + +test('tour step contentClass', async () => { + const wrapper = mount(Wrapper, { + attrs: { + contentClass: 'test-tour-class', + }, + }) + + await delay(50) + expect(wrapper.find('.test-tour-class').exists()).toBe(true) + + wrapper.unmount() +}) + +test('tour step contentStyle', async () => { + const wrapper = mount(Wrapper, { + attrs: { + contentStyle: { + background: 'red', + }, + }, + }) + + await delay(50) + expect(wrapper.find('.var-tour__content').attributes('style')).toContain('background: red') + + wrapper.unmount() +}) + +test('tour step width', async () => { + const wrapper = mount(Wrapper, { + attrs: { + width: 100, + }, + }) + + await delay(50) + expect(wrapper.find('.var-tour__content').attributes('style')).toContain('width: 100px') + + await wrapper.setProps({ + width: '200', + }) + expect(wrapper.find('.var-tour__content').attributes('style')).toContain('width: 200px') + + wrapper.unmount() +}) + +test('tour step prevButtonText', async () => { + const wrapper = mount(Wrapper) + + await delay(50) + wrapper.find('.var-tour__next-button').trigger('click') + + await nextTick() + + expect(wrapper.find('.var-tour__previous-button').text()).toBe('上一步') + + await wrapper.setProps({ + prevButtonText: 'prev', + }) + + expect(wrapper.find('.var-tour__previous-button').text()).toBe('prev') + + wrapper.unmount() +}) + +test('tour step nextButtonText', async () => { + const wrapper = mount(Wrapper) + + await delay(50) + + expect(wrapper.find('.var-tour__next-button').text()).toBe('下一步') + + await wrapper.setProps({ + nextButtonText: 'next', + }) + + expect(wrapper.find('.var-tour__next-button').text()).toBe('next') + + wrapper.unmount() +}) + +test('tour step prevButtonColor', async () => { + const wrapper = mount(Wrapper, { + attrs: { + prevButtonColor: 'blue', + }, + }) + + await delay(50) + wrapper.find('.var-tour__next-button').trigger('click') + + await nextTick() + + expect(wrapper.find('.var-tour__previous-button').attributes('style')).toContain('background: blue') + + wrapper.unmount() +}) + +test('tour step nextButtonColor', async () => { + const wrapper = mount(Wrapper, { + attrs: { + nextButtonColor: 'blue', + }, + }) + + await delay(50) + + expect(wrapper.find('.var-tour__next-button').attributes('style')).toContain('background: blue') + + wrapper.unmount() +}) + +test('tour step prevButtonTextColor', async () => { + const wrapper = mount(Wrapper, { + attrs: { + prevButtonTextColor: 'blue', + }, + }) + + await delay(50) + wrapper.find('.var-tour__next-button').trigger('click') + + await nextTick() + + expect(wrapper.find('.var-tour__previous-button').attributes('style')).toContain('color: blue') + + wrapper.unmount() +}) + +test('tour step nextButtonTextColor', async () => { + const wrapper = mount(Wrapper, { + attrs: { + nextButtonTextColor: 'blue', + }, + }) + + await delay(50) + + expect(wrapper.find('.var-tour__next-button').attributes('style')).toContain('color: blue') + + wrapper.unmount() +}) + +describe('tour events', () => { + test('tour onChange', async () => { + const onChange = vi.fn() + const wrapper = mount(Wrapper, { + props: { + onChange, + }, + }) + + await delay(50) + + wrapper.find('.var-tour__next-button').trigger('click') + + expect(onChange).toHaveBeenCalledWith(1) + + await nextTick() + wrapper.find('.var-tour__previous-button').trigger('click') + + expect(onChange).toHaveBeenCalledWith(0) + + wrapper.unmount() + }) + + test('tour onClose', async () => { + const onClose = vi.fn() + let wrapper = mount(Wrapper, { + props: { + onClose, + closeable: true, + }, + }) + + await delay(50) + + wrapper.find('.var-tour__close-icon').trigger('click') + expect(onClose).toHaveBeenCalledTimes(1) + + wrapper.unmount() + + wrapper = mount(Wrapper, { + props: { + onClose, + }, + }) + + await delay(50) + + wrapper.find('.var-tour__next-button').trigger('click') + + await nextTick() + + wrapper.find('.var-tour__next-button').trigger('click') + + expect(onClose).toHaveBeenCalledTimes(2) + wrapper.unmount() + }) + + test('tour onFinish', async () => { + const onFinish = vi.fn() + const wrapper = mount(Wrapper, { + props: { + onFinish, + closeable: true, + }, + }) + + await delay(50) + + wrapper.find('.var-tour__next-button').trigger('click') + + await nextTick() + + wrapper.find('.var-tour__next-button').trigger('click') + + expect(onFinish).toHaveBeenCalled() + wrapper.unmount() + }) + + test('tour click overlay and closeOnClickOverlay', async () => { + const onClickOverlay = vi.fn() + const wrapper = mount(Wrapper, { + props: { + onClickOverlay, + closeOnClickOverlay: false, + }, + }) + + await delay(50) + + await wrapper.find('.var-tour__overlay').trigger('click') + expect(onClickOverlay).toHaveBeenCalledTimes(1) + expect(wrapper.vm.show).toBe(true) + + await wrapper.setProps({ closeOnClickOverlay: true }) + await wrapper.find('.var-tour__overlay').trigger('click') + expect(onClickOverlay).toHaveBeenCalledTimes(2) + expect(wrapper.vm.show).toBe(false) + + wrapper.unmount() + }) + + test('tour keyboard escape and closeOnKeyEscape', async () => { + const onKeyEscape = vi.fn() + const wrapper = mount(Wrapper, { + props: { + onKeyEscape, + closeOnKeyEscape: false, + }, + }) + + await delay(50) + + await triggerKeyboard(window, 'keydown', { key: 'Escape' }) + expect(onKeyEscape).toBeCalledTimes(1) + expect(wrapper.vm.show).toBe(true) + + await wrapper.setProps({ closeOnKeyEscape: true }) + await triggerKeyboard(window, 'keydown', { key: 'Escape' }) + expect(onKeyEscape).toBeCalledTimes(2) + expect(wrapper.vm.show).toBe(false) + + wrapper.unmount() + }) +}) 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..41e250d6969 --- /dev/null +++ b/packages/varlet-ui/src/tour/docs/en-US.md @@ -0,0 +1,283 @@ +# Tour + +### Intro + +A popup component for guiding users through a product. + +### Basic Usage + +```html + + + + Begin Tour + + + + + Upload + Save + + + + + + + + + +``` + +### Non Modal + +Use `:overlay="false"` to make Tour non-modal. At the meantime it is recommended to use with `type="primary"` to emphasize the guide itself. + +```html + + + + Begin Tour + + + + + Upload + Save + + + + + + + + + +``` + +### Placement + +Change the placement of the guide relative to the target, there are 12 placements available. When target is empty the guide will show in the center. + +```html + + + + Begin Tour + + + + + Upload + Save + + + + + + + + + +``` + +### Custom Indicator + +```html + + + + Begin Tour + + + + + Upload + Save + + + + + + + + + {{ current + 1 }} / {{ total }} + + + +``` + +## API + +### Props + +#### Tour Props + +| Prop | Description | Type | Default | +|--------------------------|--------------------------------------------------------------------------------------------------------------|--------------------------------|-----------| +| `v-model:show` | show tour | _boolean_ | `-` | +| `v-model:current` | What is the current step | _number_ | `0` | +| `closeable` | Whether to show a close button | _boolean_ | `-` | +| `type` | Type,can be set to `default` `primary` | _string_ | `default` | +| `arrow` | Whether to display the arrow | _boolean_ | `true` | +| `placement` | Tour popup placement | _Placement_ | `bottom` | +| `content-class` | Tour body class | _string_ | `-` | +| `content-style` | Tour body style | _object_ | `-` | +| `overlay` | Whether to display the overlay | _boolean_ | `true` | +| `overlay-class` | Custom overlay class | _string_ | `-` | +| `overlay-style` | Custom overlay style | _string_ | `-` | +| `lock-scroll` | Whether to disable scrolling penetration, scrolling the Tour when disabled will not cause the body to scroll | _boolean_ | `true` | +| `close-on-click-overlay` | Whether to click the overlay to close the Tour | _boolean_ | `true` | +| `close-on-key-escape` | Whether to support keyboard ESC to close the Tour | _boolean_ | `true` | +| `teleport` | The location of the tooltip mount | _TeleportProps['to'] \| false_ | `body` | + +#### TourStep Props + +| Prop | Description | Type | Default | +|--------------------------|------------------------------------------|-------------------------|-------------| +| `target` | Get the element the guide card points to | _string \| HTMLElement_ | `-` | +| `title` | Tour title | _string_ | `-` | +| `message` | Tour message content | _string_ | `-` | +| `closeable` | Whether to show a close button | _boolean_ | `undefined` | +| `arrow` | Whether to display the arrow | _boolean_ | `undefined` | +| `placement` | Tour popup placement | _Placement_ | `-` | +| `width` | Tour width | _string \| number_ | `-` | +| `prev-button-text` | Previous button text | _string_ | `next` | +| `next-button-text` | Next button text | _string_ | `previous` | +| `prev-button-text-color` | Previous button text color | _string_ | `-` | +| `next-button-text-color` | Next button text color | _string_ | `-` | +| `prev-button-color` | Previous button background color | _string_ | `-` | +| `next-button-color` | Next button background color | _string_ | `-` | +| `content-class` | Tour body class | _string_ | `-` | +| `content-style` | Tour body style | _object_ | `-` | +| `overlay` | Whether to display the overlay | _boolean_ | `undefined` | +| `overlay-class` | Custom overlay class | _string_ | `-` | +| `overlay-style` | Custom overlay style | _string_ | `-` | + + +### Placement + +| Prop | Description | +|----------------|------------------------| +| `top` | Top center position | +| `top-start` | Top left position | +| `top-end` | Top right position | +| `bottom` | Bottom center position | +| `bottom-start` | Bottom left position | +| `bottom-end` | Bottom right position | +| `right` | Right center position | +| `right-start` | Top right position | +| `right-end` | Bottom right position | +| `left` | Left center position | +| `left-start` | Top left position | +| `left-end` | Bottom left position | + +### Events + +#### Tour Events + +| Event | Description | Prop | +|-----------------|-------------------------------------|-------------------| +| `close` | Triggered when the Tour is close | `-` | +| `finish` | Triggered when the Tour is finished | `-` | +| `change` | Triggered when step are change | `current: number` | +| `click-overlay` | Triggered when clicking on overlay | `-` | +| `key-escape` | Triggered when click keyboard ESC | `-` | + +### Slots + +#### Tour Slots + +| Name | Description | Prop | +|----------------|-------------------------|--------------------------------------| +| `default` | TourStep component list | `-` | +| `close-button` | Close button | `-` | +| `indicators` | Indicators | `{ current: number, total: number }` | + +#### TourStep Slots + +| Name | Description | Prop | +|-----------|-----------------|------| +| `title` | Title | `-` | +| `default` | Message content | `-` | + +### Style Variables + +Here are the CSS variables used by the component. Styles can be customized using [StyleProvider](#/en-US/style-provider). + +#### Tour Variables + +| Variable | Default | +|----------------------------------------------------|--------------------------------------| +| `--tour-width` | `280px` | +| `--tour-background` | `var(--color-surface-container-low)` | +| `--tour-border-radius` | `3px` | +| `--tour-title-padding` | `20px 20px 0` | +| `--tour-title-color` | `#555` | +| `--tour-title-font-size` | `var(--font-size-lg)` | +| `--tour-message-color` | `#888` | +| `--tour-message-padding` | `12px 20px` | +| `--tour-message-font-size` | `var(--font-size-md)` | +| `--tour-message-line-height` | `24px` | +| `--tour-indicator-background-color` | `rgba(0, 0, 0, 0.15)` | +| `--tour-indicator-active-background-color` | `var(--color-primary)` | +| `--tour-actions-padding` | `0 12px 12px 20px` | +| `--tour-next-button-color` | `var(--color-primary)` | +| `--tour-prev-button-color` | `var(--color-primary)` | +| `--tour-close-button-right` | `20px` | +| `--tour-close-button-top` | `20px` | +| `--tour-primary-background` | `var(--color-primary)` | +| `--tour-primary-title-color` | `var(--color-on-primary)` | +| `--tour-primary-message-color` | `var(--color-on-info)` | +| `--tour-primary-indicator-background-color` | `rgba(255, 255, 255, 0.15)` | +| `--tour-primary-indicator-active-background-color` | `var(--color-on-primary)` | +| `--tour-primary-next-button-color` | `var(--color-on-primary)` | +| `--tour-primary-prev-button-color` | `var(--color-on-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..0eb1c90209e --- /dev/null +++ b/packages/varlet-ui/src/tour/docs/zh-CN.md @@ -0,0 +1,282 @@ +# 漫游式引导 + +### 介绍 + +用于分步引导用户了解产品功能的气泡组件。 + +### 基本使用 + +```html + + + + 开始引导 + + + + + 上传 + 保存 + + + + + + + + + +``` + +### 非模态 + +使用 `:overlay="false"` 可以将引导变为非模态, 同时为了强调引导本身,建议与 `type="primary"` 组合使用。 + +```html + + + + 开始引导 + + + + + 上传 + 保存 + + + + + + + + + +``` + +### 弹出位置 + +改变引导相对于目标的位置,共有 12 种位置可供选择。 当 `target` 为空时引导将会展示在正中央。 + +```html + + + + 开始引导 + + + + + 上传 + 保存 + + + + + + + + + +``` + +### 自定义指示器 + +```html + + + + 开始引导 + + + + + 上传 + 保存 + + + + + + + + + {{ current + 1 }} / {{ total }} + + + +``` + +## API + +### 属性 + +#### Tour Props + +| 参数 | 说明 | 类型 | 默认值 | +|--------------------------|-----------------------------------------------------|--------------------------------|-----------| +| `v-model:show` | 显示引导 | _boolean_ | `-` | +| `v-model:current` | 当前处于哪一步 | _number_ | `0` | +| `closeable` | 是否显示关闭按钮 | _boolean_ | `-` | +| `type` | 类型,可选值为 `default` `primary` | _string_ | `default` | +| `arrow` | 是否显示箭头 | _boolean_ | `true` | +| `placement` | 弹出位置 | _Placement_ | `bottom` | +| `content-class` | 引导卡片主体区域的 class | _string_ | `-` | +| `content-style` | 引导卡片主体区域的 style | _object_ | `-` | +| `overlay` | 是否显示遮罩层 | _boolean_ | `true` | +| `overlay-class` | 自定义遮罩层的 class | _string_ | `-` | +| `overlay-style` | 自定义遮罩层的 style | _object_ | `-` | +| `lock-scroll` | 是否禁止滚动穿透,禁止时滚动弹出层不会引发 body 的滚动 | _boolean_ | `true` | +| `close-on-click-overlay` | 是否点击遮罩层关闭弹出层 | _boolean_ | `true` | +| `close-on-key-escape` | 是否支持键盘 ESC 关闭弹窗 | _boolean_ | `true` | +| `teleport` | 弹出层挂载的位置 | _TeleportProps['to'] \| false_ | `body` | + +#### TourStep Props + +| 参数 | 说明 | 类型 | 默认值 | +|--------------------------|------------------------|-------------------------|-------------| +| `target` | 引导卡片指向的元素 | _string \| HTMLElement_ | `-` | +| `title` | 标题 | _string_ | `-` | +| `message` | 内容 | _string_ | `-` | +| `closeable` | 是否显示关闭按钮 | _boolean_ | `undefined` | +| `arrow` | 是否显示箭头 | _boolean_ | `undefined` | +| `placement` | 弹出位置 | _Placement_ | `-` | +| `width` | 引导卡片宽度 | _string \| number_ | `-` | +| `prev-button-text` | 上一步按钮文字 | _string_ | `next` | +| `next-button-text` | 下一步按钮文字 | _string_ | `previous` | +| `prev-button-text-color` | 上一步按钮文字颜色 | _string_ | `-` | +| `next-button-text-color` | 下一步按钮文字颜色 | _string_ | `-` | +| `prev-button-color` | 上一步按钮背景颜色 | _string_ | `-` | +| `next-button-color` | 下一步按钮背景颜色 | _string_ | `-` | +| `content-class` | 引导卡片主体区域的 class | _string_ | `-` | +| `content-style` | 引导卡片主体区域的 style | _object_ | `-` | +| `overlay` | 是否显示遮罩层 | _boolean_ | `undefined` | +| `overlay-class` | 自定义遮罩层的 class | _string_ | `-` | +| `overlay-style` | 自定义遮罩层的 style | _string_ | `-` | + +### Placement + +| 参数 | 说明 | +|----------------|------------| +| `top` | 顶部中心位置 | +| `top-start` | 顶部左侧位置 | +| `top-end` | 顶部右侧位置 | +| `bottom` | 底部中心位置 | +| `bottom-start` | 底部左侧位置 | +| `bottom-end` | 底部右侧位置 | +| `right` | 右侧中心位置 | +| `right-start` | 右侧上方位置 | +| `right-end` | 右侧下方位置 | +| `left` | 左侧中心位置 | +| `left-start` | 左侧上方位置 | +| `left-end` | 左侧下方位置 | + +### 事件 + +#### Tour Events + +| 事件名 | 说明 | 参数 | +|-----------------|-------------------|-------------------| +| `close` | 关闭引导时触发 | `-` | +| `finish` | 引导完成时触发 | `-` | +| `change` | 步骤改变时触发 | `current: number` | +| `click-overlay` | 点击遮罩层时触发 | `-` | +| `key-escape` | 点击键盘 ESC 时触发 | `-` | + +### 插槽 + +#### Tour Slots + +| 插槽名 | 说明 | 参数 | +|----------------|-----------------|--------------------------------------| +| `default` | tourStep 组件列表 | `-` | +| `close-button` | 关闭按钮 | `-` | +| `indicators` | 指示器 | `{ current: number, total: number }` | + +#### TourStep Slots + +| 插槽名 | 说明 | 参数 | +|-----------|----|------| +| `title` | 标题 | `-` | +| `default` | 内容 | `-` | + +### 样式变量 + +以下为组件使用的 css 变量,可以使用 [StyleProvider 组件](#/zh-CN/style-provider) 进行样式定制。 + +#### Tour Variables + +| 变量名 | 默认值 | +|----------------------------------------------------|--------------------------------------| +| `--tour-width` | `280px` | +| `--tour-background` | `var(--color-surface-container-low)` | +| `--tour-border-radius` | `3px` | +| `--tour-title-padding` | `20px 20px 0` | +| `--tour-title-color` | `#555` | +| `--tour-title-font-size` | `var(--font-size-lg)` | +| `--tour-message-color` | `#888` | +| `--tour-message-padding` | `12px 20px` | +| `--tour-message-font-size` | `var(--font-size-md)` | +| `--tour-message-line-height` | `24px` | +| `--tour-indicator-background-color` | `rgba(0, 0, 0, 0.15)` | +| `--tour-indicator-active-background-color` | `var(--color-primary)` | +| `--tour-actions-padding` | `0 12px 12px 20px` | +| `--tour-next-button-color` | `var(--color-primary)` | +| `--tour-prev-button-color` | `var(--color-primary)` | +| `--tour-close-button-right` | `20px` | +| `--tour-close-button-top` | `20px` | +| `--tour-primary-background` | `var(--color-primary)` | +| `--tour-primary-title-color` | `var(--color-on-primary)` | +| `--tour-primary-message-color` | `var(--color-on-info)` | +| `--tour-primary-indicator-background-color` | `rgba(255, 255, 255, 0.15)` | +| `--tour-primary-indicator-active-background-color` | `var(--color-on-primary)` | +| `--tour-primary-next-button-color` | `var(--color-on-primary)` | +| `--tour-primary-prev-button-color` | `var(--color-on-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..cd900b65ff5 --- /dev/null +++ b/packages/varlet-ui/src/tour/example/index.vue @@ -0,0 +1,98 @@ + + + + {{ t('basicUsage') }} + {{ t('beginTour') }} + + + {{ t('upload') }} + {{ t('save') }} + + + + + + + + + {{ t('nonModel') }} + {{ t('beginTour') }} + + + {{ t('upload') }} + {{ t('save') }} + + + + + + + + + {{ t('placement') }} + {{ t('beginTour') }} + + + {{ t('upload') }} + {{ t('save') }} + + + + + + + + + {{ t('indicator') }} + {{ t('beginTour') }} + + + {{ t('upload') }} + {{ t('save') }} + + + + + + + + {{ current + 1 }} / {{ total }} + + + 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..31dd80c9b62 --- /dev/null +++ b/packages/varlet-ui/src/tour/example/locale/en-US.ts @@ -0,0 +1,15 @@ +export default { + basicUsage: 'Basic Usage', + nonModal: 'Non Modal', + placement: 'Placement', + indicator: 'Custom Indicator', + beginTour: 'Begin Tour', + upload: 'Upload', + save: 'Save', + firstTitle: 'Upload File', + firstMessage: 'Put you files here.', + secondTitle: 'Save', + secondMessage: 'Save your changes.', + thirdTitle: 'Other Actions', + thirdMessage: 'Click to see other', +} 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..e031be8c1a4 --- /dev/null +++ b/packages/varlet-ui/src/tour/example/locale/index.ts @@ -0,0 +1,17 @@ +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..e3b18dfec66 --- /dev/null +++ b/packages/varlet-ui/src/tour/example/locale/zh-CN.ts @@ -0,0 +1,15 @@ +export default { + basicUsage: '基本使用', + nonModel: '非模态', + placement: '弹出位置', + indicator: '自定义指示器', + beginTour: '开始引导', + upload: '上传', + save: '保存', + firstTitle: '上传文件', + firstMessage: '把你的文件放在这里。', + secondTitle: '保存', + secondMessage: '保存你的更改。', + thirdTitle: '其他操作', + thirdMessage: '点击查看其他。', +} diff --git a/packages/varlet-ui/src/tour/index.ts b/packages/varlet-ui/src/tour/index.ts new file mode 100644 index 00000000000..8086a3859da --- /dev/null +++ b/packages/varlet-ui/src/tour/index.ts @@ -0,0 +1,12 @@ +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 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..12bd5dee4fe --- /dev/null +++ b/packages/varlet-ui/src/tour/props.ts @@ -0,0 +1,61 @@ +import { type PropType, type TeleportProps } from 'vue' +import { popupProps } from '../popup' +import { defineListenerProp, pickProps } from '../utils/components' +import type { NeededPopperPlacement } from './usePopover' + +export type Placement = NeededPopperPlacement + +export type TourType = 'default' | 'primary' + +export interface TourGap { + offset: number | string + radius: number | string +} + +export const props = { + show: Boolean, + closeable: Boolean, + current: { + type: Number, + default: 0, + }, + type: { + type: String as PropType, + default: 'default', + }, + arrow: { + type: Boolean, + default: true, + }, + placement: { + type: String as PropType, + default: 'bottom', + }, + gap: { + type: Object as PropType, + default: () => ({ + offset: 6, + radius: 2, + }), + }, + contentClass: String, + contentStyle: Object, + teleport: { + type: [String, Object, Boolean] as PropType, + default: 'body', + }, + onClose: defineListenerProp<() => void>(), + onFinish: defineListenerProp<() => void>(), + onChange: defineListenerProp<(current: number) => void>(), + 'onUpdate:show': defineListenerProp<(show: boolean) => void>(), + 'onUpdate:current': defineListenerProp<(current: number) => void>(), + ...pickProps(popupProps, [ + 'overlay', + 'overlayClass', + 'overlayStyle', + 'closeOnClickOverlay', + 'closeOnKeyEscape', + 'onClickOverlay', + 'onKeyEscape', + ]), +} diff --git a/packages/varlet-ui/src/tour/provide.ts b/packages/varlet-ui/src/tour/provide.ts new file mode 100644 index 00000000000..ccb06ea327a --- /dev/null +++ b/packages/varlet-ui/src/tour/provide.ts @@ -0,0 +1,23 @@ +import { ComputedRef } from 'vue' +import { useChildren } from '@varlet/use' +import TourStep from '../tour-step' +import { TourStepProvider } from '../tour-step/provide' + +export type TourStepProps = InstanceType['$props'] + +export interface TourProvider { + current: ComputedRef + // updateStepProps: (props: TourStepProps) => void +} + +export const TOUR_BIND_STEP_KEY = Symbol('TOUR_BIND_STEP_KEY') + +export function useTourSteps() { + const { childProviders, length, bindChildren } = useChildren(TOUR_BIND_STEP_KEY) + + return { + length, + tourSteps: childProviders, + bindTourStep: bindChildren, + } +} diff --git a/packages/varlet-ui/src/tour/tour.less b/packages/varlet-ui/src/tour/tour.less new file mode 100644 index 00000000000..cd7d807d885 --- /dev/null +++ b/packages/varlet-ui/src/tour/tour.less @@ -0,0 +1,143 @@ +:root { + --tour-width: 280px; + --tour-background: var(--color-surface-container-low); + --tour-border-radius: 3px; + --tour-title-padding: 20px 20px 0; + --tour-title-color: #555; + --tour-title-font-size: var(--font-size-lg); + --tour-message-color: #888; + --tour-message-padding: 12px 20px; + --tour-message-font-size: var(--font-size-md); + --tour-message-line-height: 24px; + --tour-indicator-background-color: rgba(0, 0, 0, 0.15); + --tour-indicator-active-background-color: var(--color-primary); + --tour-actions-padding: 0 12px 12px 20px; + --tour-next-button-color: var(--color-primary); + --tour-prev-button-color: var(--color-primary); + --tour-close-button-right: 20px; + --tour-close-button-top: 20px; + --tour-primary-background: var(--color-primary); + --tour-primary-title-color: var(--color-on-primary); + --tour-primary-message-color: var(--color-on-info); + --tour-primary-indicator-background-color: rgba(255, 255, 255, 0.15); + --tour-primary-indicator-active-background-color: var(--color-on-primary); + --tour-primary-next-button-color: var(--color-on-primary); + --tour-primary-prev-button-color: var(--color-on-primary); +} + +.var-tour { + &--primary { + --tour-background: var(--tour-primary-background); + --tour-title-color: var(--tour-primary-title-color); + --tour-message-color: var(--tour-primary-message-color); + --tour-indicator-background-color: var(--tour-primary-indicator-background-color); + --tour-indicator-active-background-color: var(--tour-primary-indicator-active-background-color); + --tour-next-button-color: var(--tour-primary-next-button-color); + --tour-prev-button-color: var(--tour-primary-prev-button-color); + } + + &__overlay { + position: fixed; + inset: 0; + pointer-events: none; + } + + &__overlay svg { + width: 100%; + height: 100%; + } + + &__hollow { + fill: rgba(0, 0, 0, 0.6); + cursor: pointer; + pointer-events: auto; + transition: all 0.3s var(--cubic-bezier); + } + + &__content { + width: var(--tour-width); + background: var(--tour-background); + border-radius: var(--tour-border-radius); + max-width: 100%; + overflow-wrap: break-word; + } + + &__arrow { + position: absolute; + width: 10px; + height: 10px; + background-color: inherit; + transform: rotate(45deg); + transform-origin: center; + } + + &__content[data-popper-placement='top'] &__arrow, + &__content[data-popper-placement='top-start'] &__arrow, + &__content[data-popper-placement='top-end'] &__arrow { + bottom: -5px; + } + + &__content[data-popper-placement='bottom'] &__arrow, + &__content[data-popper-placement='bottom-start'] &__arrow, + &__content[data-popper-placement='bottom-end'] &__arrow { + top: -5px; + } + + &__content[data-popper-placement='left'] &__arrow, + &__content[data-popper-placement='left-start'] &__arrow, + &__content[data-popper-placement='left-end'] &__arrow { + right: -5px; + } + + &__content[data-popper-placement='right'] &__arrow, + &__content[data-popper-placement='right-start'] &__arrow, + &__content[data-popper-placement='right-end'] &__arrow { + left: -5px; + } + + &__close-icon { + position: absolute; + top: var(--tour-close-button-top); + right: var(--tour-close-button-right); + display: flex; + align-items: center; + justify-content: center; + color: var(--tour-title-color); + cursor: pointer; + } + + &__actions { + display: flex; + align-items: center; + padding: var(--tour-actions-padding); + } + + &__indicators { + display: flex; + } + + &__indicator { + background-color: var(--tour-indicator-background-color); + width: 6px; + height: 6px; + margin-right: 6px; + border-radius: 50%; + } + + &__indicator&--active { + background-color: var(--tour-indicator-active-background-color); + } + + &__buttons { + margin-left: auto; + } + + &__previous-button { + margin-right: 6px; + color: var(--tour-prev-button-color); + } + + &__next-button { + color: var(--tour-next-button-color); + } +} diff --git a/packages/varlet-ui/src/tour/usePopover.ts b/packages/varlet-ui/src/tour/usePopover.ts new file mode 100644 index 00000000000..32c06564024 --- /dev/null +++ b/packages/varlet-ui/src/tour/usePopover.ts @@ -0,0 +1,121 @@ +import { computed, ComputedRef, onUnmounted, ref, watch, type Ref } from 'vue' +import { type Placement as PopperPlacement } from '@popperjs/core' +import arrowModifier from '@popperjs/core/lib/modifiers/arrow.js' +import computeStyles from '@popperjs/core/lib/modifiers/computeStyles.js' +import flip from '@popperjs/core/lib/modifiers/flip.js' +import offset from '@popperjs/core/lib/modifiers/offset.js' +import preventOverflow from '@popperjs/core/lib/modifiers/preventOverflow.js' +import { createPopper } from '@popperjs/core/lib/popper-lite.js' +import { type Instance, type Modifier } from '@popperjs/core/lib/types' +import { getRect, toNumber } from '@varlet/shared' +import { onWindowResize } from '@varlet/use' +import { useStack } from '../context/stack' +import { useZIndex } from '../context/zIndex' +import { TourGap } from './props' + +export type NeededPopperPlacement = Exclude + +export type Placement = NeededPopperPlacement + +export interface UsePopoverOptions { + show: ComputedRef + arrow: ComputedRef + gap: ComputedRef + placement: ComputedRef +} + +export function usePopover(options: UsePopoverOptions) { + const { show, arrow, gap, placement } = options + const host: Ref = ref(null) + const popover: Ref = ref(null) + const arrowRef: Ref = ref(null) + const arrowSize = computed(() => { + if (arrowRef.value) { + return getRect(arrowRef.value).height / 2 + } + return 0 + }) + + const { zIndex } = useZIndex(() => show.value, 1) + const { onStackTop } = useStack(() => show.value, zIndex) + + let popoverInstance: Instance | null = null + + watch(() => [arrowRef.value, gap.value.offset, gap.value.radius, placement.value], resize) + watch(() => [host.value, popover.value], createPopperInstance) + + onWindowResize(resize) + onUnmounted(destroyPopperInstance) + + function createPopperInstance() { + destroyPopperInstance() + + if (!host.value || !popover.value) { + return + } + + popoverInstance = createPopper(host.value, popover.value, getPopperOptions()) + } + + function destroyPopperInstance() { + popoverInstance?.destroy() + popoverInstance = null + } + + function getPopperOptions() { + const modifiers: Modifier[] = [ + { + ...flip, + enabled: show.value, + options: { + fallbackPlacements: ['bottom', 'right', 'left', 'top'], + }, + }, + { + ...offset, + options: { + offset: [0, toNumber(gap.value.offset) + arrowSize.value], + }, + }, + { + ...computeStyles, + options: { + adaptive: false, + gpuAcceleration: false, + }, + enabled: show.value, + }, + { + ...preventOverflow, + enabled: show.value, + }, + { + ...arrowModifier, + options: { + element: arrowRef.value, + padding: gap.value.radius, + }, + enabled: show.value && arrow.value, + }, + ] + + return { + placement: placement.value, + modifiers, + } + } + + // expose + function resize() { + popoverInstance?.setOptions(getPopperOptions()) + } + + return { + popover, + arrowRef, + zIndex, + host, + resize, + onStackTop, + } +} diff --git a/packages/varlet-ui/src/tour/usePosition.ts b/packages/varlet-ui/src/tour/usePosition.ts new file mode 100644 index 00000000000..9334d4299e7 --- /dev/null +++ b/packages/varlet-ui/src/tour/usePosition.ts @@ -0,0 +1,62 @@ +import { computed, ComputedRef, ref, watch, type Ref } from 'vue' +import { getRect, inViewport, toNumber } from '@varlet/shared' +import { onWindowResize } from '@varlet/use' +import { TourGap } from './props' + +export interface PosInfo { + top: number + left: number + width: number + height: number + radius: number +} + +export function usePosition(target: Ref, open: ComputedRef, gap: ComputedRef) { + const innerPosInfo: Ref = ref(null) + const targetEl = computed(() => target.value) + + const posInfo: ComputedRef = computed(() => { + if (!innerPosInfo.value) { + return null + } + + const { top, left, width, height } = innerPosInfo.value + const { offset, radius } = gap.value + const gapOffset = toNumber(offset) + const gapRadius = toNumber(radius) + + return { + left: left - gapOffset, + top: top - gapOffset, + width: width + gapOffset * 2, + height: height + gapOffset * 2, + radius: gapRadius, + } + }) + + watch([targetEl, open], updatePosInfo, { immediate: true }) + onWindowResize(updatePosInfo) + + function updatePosInfo() { + if (!open.value || !targetEl.value) { + innerPosInfo.value = null + return + } + if (!inViewport(targetEl.value)) { + targetEl.value.scrollIntoView({ block: 'center' }) + } + + const { top, left, width, height } = getRect(targetEl.value) + innerPosInfo.value = { + top, + left, + width, + height, + radius: 0, + } + } + + return { + posInfo, + } +} diff --git a/packages/varlet-ui/types/styleVars.d.ts b/packages/varlet-ui/types/styleVars.d.ts index 4d13dbad5b9..7c01817ca68 100644 --- a/packages/varlet-ui/types/styleVars.d.ts +++ b/packages/varlet-ui/types/styleVars.d.ts @@ -872,6 +872,30 @@ interface BaseStyleVars { '--tooltip-success-text-color'?: string '--tooltip-warning-text-color'?: string '--tooltip-danger-text-color'?: string + '--tour-width'?: string + '--tour-background'?: string + '--tour-border-radius'?: string + '--tour-title-padding'?: string + '--tour-title-color'?: string + '--tour-title-font-size'?: string + '--tour-message-color'?: string + '--tour-message-padding'?: string + '--tour-message-font-size'?: string + '--tour-message-line-height'?: string + '--tour-indicator-background-color'?: string + '--tour-indicator-active-background-color'?: string + '--tour-actions-padding'?: string + '--tour-next-button-color'?: string + '--tour-prev-button-color'?: string + '--tour-close-button-right'?: string + '--tour-close-button-top'?: string + '--tour-primary-background'?: string + '--tour-primary-title-color'?: string + '--tour-primary-message-color'?: string + '--tour-primary-indicator-background-color'?: string + '--tour-primary-indicator-active-background-color'?: string + '--tour-primary-next-button-color'?: string + '--tour-primary-prev-button-color'?: string '--uploader-action-background'?: string '--uploader-action-icon-color'?: string '--uploader-action-icon-size'?: string diff --git a/packages/varlet-ui/varlet.config.mjs b/packages/varlet-ui/varlet.config.mjs index 5114f034e58..1c1c27c188c 100644 --- a/packages/varlet-ui/varlet.config.mjs +++ b/packages/varlet-ui/varlet.config.mjs @@ -400,6 +400,14 @@ export default defineConfig({ doc: 'code', type: 2, }, + { + text: { + 'zh-CN': 'Tour 漫游式引导', + 'en-US': 'Tour', + }, + doc: 'tour', + type: 2, + }, { text: { 'zh-CN': '导航组件',