diff --git a/docs/.vitepress/crowdin/en-US/pages/component.json b/docs/.vitepress/crowdin/en-US/pages/component.json index 8ee7484b12f6c..a1355c701f2b0 100644 --- a/docs/.vitepress/crowdin/en-US/pages/component.json +++ b/docs/.vitepress/crowdin/en-US/pages/component.json @@ -215,6 +215,11 @@ "link": "/timeline", "text": "Timeline" }, + { + "link": "/tour", + "text": "Tour", + "promotion": "2.5.0" + }, { "link": "/tree", "text": "Tree" diff --git a/docs/en-US/component/tour.md b/docs/en-US/component/tour.md new file mode 100644 index 0000000000000..0d43a1760af86 --- /dev/null +++ b/docs/en-US/component/tour.md @@ -0,0 +1,129 @@ +--- +title: Tour +lang: en-US +--- + +# Tour + +A popup component for guiding users through a product. Use when you want to guide users through a product. + +## Basic usage + +The most basic usage + +:::demo + +tour/basic + +::: + +## Non modal + +Use `:mask="false"` to make Tour non-modal. At the meantime it is recommended to use with `type="primary"` to emphasize the guide itself. + +:::demo + +tour/non-modal + +::: + +## 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. + +:::demo + +tour/placement + +::: + +## Custom mask style + +Custom mask style. + +:::demo + +tour/mask + +::: + +## Custom indicator + +Custom indicator. + +:::demo + +tour/indicator + +::: + + +## Tour API + +:::tip +tour-step component configuration with the same name has higher priority +::: + +### Tour Attributes + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| show-arrow | whether to show the arrow | `boolean` | true | +| placement | position of the guide card relative to the target element | ^[enum]`'top' \| 'top-start' \| 'top-end' \| 'bottom' \| 'bottom-start' \| 'bottom-end' \| 'left' \| 'left-start' \| 'left-end' \| 'right' \| 'right-start' \| 'right-end'` | `bottom` | +| content-style | custom style for content | `CSSProperties` | - | +| mask | whether to enable masking, change mask style and fill color by pass custom props | `boolean` \| ^[Object]`{ style?: CSSProperties; color?: string; }` | `true` | +| type | type, affects the background color and text color | `default` \| `primary` | `default` | +| model-value / v-model | open tour | `boolean` | - | +| current / v-model:current | what is the current step | `number` | - | +| scroll-into-view-options | support pass custom scrollIntoView options | `boolean` \| `ScrollIntoViewOptions` | ^[Object]`{ block: 'center' }` | +| z-index | Tour's zIndex | `number` | `2001` | +| show-close | whether to show a close button | `boolean` | `true` | +| close-icon | custom close icon, default is Close | `string` \| `Component` | - | +| close-on-press-escape | whether the Dialog can be closed by pressing ESC | `boolean` | `true` | +| target-area-clickable | whether the target element can be clickable, when using mask | `boolean` | `true` | + +### Tour slots + +| Name | Description | +| ------------------- | -------------------------- | +| default | tourStep component list | +| indicators | custom indicator, The scope parameter is `{ current, total }` | + +### Tour events + +| Name | Description | Type | +| --- | --- | --- | +| close | callback function on shutdown | ^[Function]`(current: number) => void` | +| finish | callback function on finished | ^[Function]`() => void` | +| change | callback when the step changes | ^[Function]`(current: number) => void` | + +### TourStep Attributes + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| target | get the element the guide card points to. Empty makes it show in center of screen | `HTMLElement` | - | +| show-arrow | whether to show the arrow | `boolean` | `true` | +| title | title | `string` | - | +| description | description | `string` | - | +| placement | position of the guide card relative to the target element | ^[enum]`'top' \| 'top-start' \| 'top-end' \| 'bottom' \| 'bottom-start' \| 'bottom-end' \| 'left' \| 'left-start' \| 'left-end' \| 'right' \| 'right-start' \| 'right-end'` | `bottom` | +| content-style | custom style for content | `CSSProperties` | - | +| mask | whether to enable masking, change mask style and fill color by pass custom props | `boolean` \| ^[Object]`{ style?: CSSProperties; color?: string; }` | `true` | +| type | type, affects the background color and text color | `default` \| `primary` | `default` | +| next-button-props | properties of the Next button | ^[Object]`{ children: VueNode \| string; onClick: Function }` | - | +| prev-button-props | properties of the previous button | ^[Object]`{ children: VueNode \| string; onClick: Function }` | - | +| scroll-into-view-options | support pass custom scrollIntoView options, the default follows the `scrollIntoViewOptions` property of Tour | `boolean` \| `ScrollIntoViewOptions` | - | +| show-close | whether to show a close button | `boolean` | `true` | +| close-icon | custom close icon, default is Close | `string` \| `Component` | - | + +### TourStep slots + +| Name | Description | +| ------------------- | ---------------------------- | +| default | description | +| title | title | + +### TourStep events + +| Name | Description | Arguments | +| ----------- | ----------------------------- | ---------- | +| close | callback function on shutdown | ^[Function]`() => void` | \ No newline at end of file diff --git a/docs/examples/tour/basic.vue b/docs/examples/tour/basic.vue new file mode 100644 index 0000000000000..aeb50402e2394 --- /dev/null +++ b/docs/examples/tour/basic.vue @@ -0,0 +1,43 @@ + + + diff --git a/docs/examples/tour/indicator.vue b/docs/examples/tour/indicator.vue new file mode 100644 index 0000000000000..8846a5a6c2650 --- /dev/null +++ b/docs/examples/tour/indicator.vue @@ -0,0 +1,44 @@ + + + diff --git a/docs/examples/tour/mask.vue b/docs/examples/tour/mask.vue new file mode 100644 index 0000000000000..ebd95079b3a0b --- /dev/null +++ b/docs/examples/tour/mask.vue @@ -0,0 +1,58 @@ + + + diff --git a/docs/examples/tour/non-modal.vue b/docs/examples/tour/non-modal.vue new file mode 100644 index 0000000000000..6417de89bea28 --- /dev/null +++ b/docs/examples/tour/non-modal.vue @@ -0,0 +1,41 @@ + + + diff --git a/docs/examples/tour/placement.vue b/docs/examples/tour/placement.vue new file mode 100644 index 0000000000000..080b48bdfeb58 --- /dev/null +++ b/docs/examples/tour/placement.vue @@ -0,0 +1,33 @@ + + + diff --git a/packages/components/index.ts b/packages/components/index.ts index f44727a243de2..e86d79d55e948 100644 --- a/packages/components/index.ts +++ b/packages/components/index.ts @@ -70,6 +70,7 @@ export * from './tree-v2' export * from './upload' export * from './virtual-list' export * from './watermark' +export * from './tour' // plugins export * from './infinite-scroll' diff --git a/packages/components/tour-step/style/css.ts b/packages/components/tour-step/style/css.ts new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/components/tour-step/style/index.ts b/packages/components/tour-step/style/index.ts new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/components/tour/__tests__/tour.test.tsx b/packages/components/tour/__tests__/tour.test.tsx new file mode 100644 index 0000000000000..638186a97e5d7 --- /dev/null +++ b/packages/components/tour/__tests__/tour.test.tsx @@ -0,0 +1,166 @@ +import { nextTick, ref } from 'vue' +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, test } from 'vitest' +import Tour from '../src/tour.vue' +import TourStep from '../src/step.vue' + +describe('Tour.vue', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + test('basic', () => { + mount({ + setup() { + const btnRef = ref(null) + return () => ( + <> + + + + + + ) + }, + }) + + expect(document.querySelector('.el-tour__title')?.innerHTML).toEqual( + 'cover title' + ) + expect(document.querySelector('.el-tour__body span')?.innerHTML).toEqual( + 'cover description.' + ) + }) + + test('controlled current', async () => { + const wrapper = mount({ + setup() { + const btnRef = ref(null) + const current = ref(0) + return () => ( + <> + + + + + + + ) + }, + }) + + expect(document.querySelector('.el-tour__title')?.innerHTML).toEqual( + 'first' + ) + wrapper.find('button').trigger('click') + await nextTick() + expect(document.querySelector('.el-tour__title')?.innerHTML).toEqual( + 'second' + ) + }) + + test('no mask', () => { + mount({ + setup() { + const btnRef = ref(null) + return () => ( + <> + + + + + + ) + }, + }) + + expect(document.querySelector('.el-tour-mask')?.innerHTML).toBeFalsy() + }) + + test('custom indicator', () => { + mount({ + setup() { + const btnRef = ref(null) + const slots = { + indicators: ({ current, total }: any) => `${current + 1} / ${total}`, + default: () => ( + + ), + } + return () => ( + <> + + + + ) + }, + }) + + expect(document.querySelector('.el-tour-indicators')?.innerHTML).toBe( + '1 / 1' + ) + }) + + test('primary', () => { + mount({ + setup() { + const btnRef = ref(null) + return () => ( + <> + + + + + + ) + }, + }) + + expect(document.querySelector('.el-tour.el-tour--primary')).toBeTruthy() + }) + + test('no target', () => { + mount({ + setup() { + return () => ( + + + + ) + }, + }) + + const style = getComputedStyle(document.querySelector('.el-tour__content')!) + expect(style.position).toBe('fixed') + expect(style.top).toBe('50%') + expect(style.left).toBe('50%') + expect(style.transform).toBe('translate3d(-50%, -50%, 0)') + expect(style.maxWidth).toBe('100vw') + }) +}) diff --git a/packages/components/tour/index.ts b/packages/components/tour/index.ts new file mode 100644 index 0000000000000..1344115d257df --- /dev/null +++ b/packages/components/tour/index.ts @@ -0,0 +1,13 @@ +import { withInstall, withNoopInstall } from '@element-plus/utils' +import Tour from './src/tour.vue' +import TourStep from './src/step.vue' + +export const ElTour = withInstall(Tour, { + TourStep, +}) +export const ElTourStep = withNoopInstall(TourStep) +export default ElTour + +export * from './src/tour' + +export type { TourMask, TourGap, TourBtnProps } from './src/types' diff --git a/packages/components/tour/src/content.ts b/packages/components/tour/src/content.ts new file mode 100644 index 0000000000000..83bfc7b76cd11 --- /dev/null +++ b/packages/components/tour/src/content.ts @@ -0,0 +1,71 @@ +import { buildProps, definePropType } from '@element-plus/utils' +import type { ExtractPropTypes } from 'vue' +import type { Placement, Strategy, VirtualElement } from '@floating-ui/dom' + +const tourStrategies = ['absolute', 'fixed'] as const + +const tourPlacements = [ + 'top-start', + 'top-end', + 'top', + 'bottom-start', + 'bottom-end', + 'bottom', + 'left-start', + 'left-end', + 'left', + 'right-start', + 'right-end', + 'right', +] as const + +export const tourContentProps = buildProps({ + /** + * @description position of the guide card relative to the target element + */ + placement: { + type: definePropType(String), + values: tourPlacements, + default: 'bottom', + }, + /** + * @description the reference dom + */ + reference: { + type: definePropType(Object), + default: null, + }, + /** + * @description position strategy of the content + */ + strategy: { + type: definePropType(String), + values: tourStrategies, + default: 'absolute', + }, + /** + * @description offset of the arrow + */ + offset: { + type: Number, + default: 10, + }, + /** + * @description @description whether to show the arrow + */ + showArrow: Boolean, + /** + * @description content's zIndex + */ + zIndex: { + type: Number, + default: 2001, + }, +}) + +export type TourContentProps = ExtractPropTypes + +export const tourContentEmits = { + close: () => true, +} +export type TourContentEmits = typeof tourContentEmits diff --git a/packages/components/tour/src/content.vue b/packages/components/tour/src/content.vue new file mode 100644 index 0000000000000..d2a1f0f25669c --- /dev/null +++ b/packages/components/tour/src/content.vue @@ -0,0 +1,64 @@ + + diff --git a/packages/components/tour/src/helper.ts b/packages/components/tour/src/helper.ts new file mode 100644 index 0000000000000..fe6d6464ead7c --- /dev/null +++ b/packages/components/tour/src/helper.ts @@ -0,0 +1,325 @@ +import { + camelize, + computed, + isVNode, + onBeforeUnmount, + onMounted, + ref, + unref, + watch, + watchEffect, +} from 'vue' +import { + arrow, + autoUpdate, + computePosition, + detectOverflow, + flip, + offset as offsetMiddelware, + shift, +} from '@floating-ui/dom' +import { hasOwn, isArray, isClient, keysOf } from '@element-plus/utils' + +import type { + CSSProperties, + Component, + ComputedRef, + InjectionKey, + Ref, + SetupContext, + VNode, +} from 'vue' +import type { UseNamespaceReturn } from '@element-plus/hooks' +import type { PosInfo, TourGap, TourMask } from './types' +import type { + ComputePositionReturn, + Middleware, + Placement, + Strategy, + VirtualElement, +} from '@floating-ui/dom' + +export const useTarget = ( + target: Ref, + open: Ref, + gap: Ref, + mergedMask: Ref, + scrollIntoViewOptions: Ref +) => { + const posInfo: Ref = ref(null) + + const updatePosInfo = () => { + if (!target.value || !open.value) { + posInfo.value = null + return + } + if (!isInViewPort(target.value) && open.value) { + target.value.scrollIntoView(scrollIntoViewOptions.value) + } + const { left, top, width, height } = target.value.getBoundingClientRect() + posInfo.value = { + left, + top, + width, + height, + radius: 0, + } + } + + onMounted(() => { + watch( + [open, target], + () => { + updatePosInfo() + }, + { + immediate: true, + } + ) + window.addEventListener('resize', updatePosInfo) + }) + + onBeforeUnmount(() => { + window.removeEventListener('resize', updatePosInfo) + }) + + const getGapOffset = (index: number) => + (isArray(gap.value.offset) ? gap.value.offset[index] : gap.value.offset) ?? + 6 + + const mergedPosInfo = computed(() => { + if (!posInfo.value) return posInfo.value + + const gapOffsetX = getGapOffset(0) + const gapOffsetY = getGapOffset(1) + const gapRadius = gap.value?.radius || 2 + + return { + left: posInfo.value.left - gapOffsetX, + top: posInfo.value.top - gapOffsetY, + width: posInfo.value.width + gapOffsetX * 2, + height: posInfo.value.height + gapOffsetY * 2, + radius: gapRadius, + } + }) + + const triggerTarget = computed(() => { + if (!mergedMask.value || !target.value || !window.DOMRect) { + return target.value || undefined + } + + return { + getBoundingClientRect() { + return window.DOMRect.fromRect({ + width: mergedPosInfo.value?.width || 0, + height: mergedPosInfo.value?.height || 0, + x: mergedPosInfo.value?.left || 0, + y: mergedPosInfo.value?.top || 0, + }) + }, + } + }) + + return { + mergedPosInfo, + triggerTarget, + } +} + +export interface TourContext { + current: Ref + total: ComputedRef + showClose: Ref + closeIcon: Ref + mergedType: Ref<'default' | 'primary' | undefined> + ns: UseNamespaceReturn + slots: SetupContext['slots'] + updateModelValue(modelValue: boolean): void + onClose(): void + onFinish(): void + onChange(): void +} + +export const tourKey: InjectionKey = Symbol('ElTour') + +function isInViewPort(element: HTMLElement) { + const viewWidth = window.innerWidth || document.documentElement.clientWidth + const viewHeight = window.innerHeight || document.documentElement.clientHeight + const { top, right, bottom, left } = element.getBoundingClientRect() + + return top >= 0 && left >= 0 && right <= viewWidth && bottom <= viewHeight +} + +const isSameProps = (a: Record, b: Record) => { + if (Object.keys(a).length !== Object.keys(b).length) return false + for (const key in a) { + if (a[key] !== b[key]) { + return false + } + } + return true +} + +export function isSameSteps(a: any[], b: any[]) { + if (a.length !== b.length) return false + for (const [index] of a.entries()) { + if (isSameProps(a[index], b[index])) { + return false + } + } + return true +} + +export const getNormalizedProps = (node: VNode, booleanKeys: string[]) => { + if (!isVNode(node)) { + return {} + } + + const raw = node.props || {} + const type = (node.type as any)?.props || {} + const props: Record = {} + Object.keys(type).forEach((key) => { + if (hasOwn(type[key], 'default')) { + props[key] = type[key].default + } + }) + + Object.keys(raw).forEach((key) => { + const cameKey = camelize(key) + props[cameKey] = raw[key] + if (booleanKeys.includes(cameKey) && props[cameKey] === '') { + props[cameKey] = true + } + }) + + return props +} + +export const useFloating = ( + referenceRef: Ref, + contentRef: Ref, + arrowRef: Ref, + placement: Ref, + strategy: Ref, + offset: Ref, + zIndex: Ref, + showArrow: Ref +) => { + const x = ref() + const y = ref() + const middlewareData = ref({}) + + const states = { + x, + y, + placement, + strategy, + middlewareData, + } as const + + const middleware = computed(() => { + const _middleware: Middleware[] = [ + offsetMiddelware(unref(offset)), + flip(), + shift(), + overflowMiddleware(), + ] + + if (unref(showArrow) && unref(arrowRef)) { + _middleware.push( + arrow({ + element: unref(arrowRef)!, + }) + ) + } + return _middleware + }) + + const update = async () => { + if (!isClient) return + + const referenceEl = unref(referenceRef) + const contentEl = unref(contentRef) + if (!referenceEl || !contentEl) return + + const data = await computePosition(referenceEl, contentEl, { + placement: unref(placement), + strategy: unref(strategy), + middleware: unref(middleware), + }) + + keysOf(states).forEach((key) => { + states[key].value = data[key] + }) + } + + const contentStyle = computed(() => { + if (!unref(referenceRef)) { + return { + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate3d(-50%, -50%, 0)', + maxWidth: '100vw', + zIndex: unref(zIndex), + } + } + + const { overflow } = unref(middlewareData) + + return { + position: unref(strategy), + zIndex: unref(zIndex), + top: unref(y) != null ? `${unref(y)}px` : '', + left: unref(x) != null ? `${unref(x)}px` : '', + maxWidth: overflow?.maxWidth ? `${overflow?.maxWidth}px` : '', + } + }) + + const arrowStyle = computed(() => { + if (!unref(showArrow)) return {} + + const { arrow } = unref(middlewareData) + return { + left: arrow?.x != null ? `${arrow?.x}px` : '', + top: arrow?.y != null ? `${arrow?.y}px` : '', + } + }) + + let cleanup: any + onMounted(() => { + cleanup = autoUpdate(unref(referenceRef)!, unref(contentRef)!, update) + + watchEffect(() => { + update() + }) + }) + + onBeforeUnmount(() => { + cleanup && cleanup() + }) + + return { + update, + contentStyle, + arrowStyle, + } +} + +const overflowMiddleware = (): Middleware => { + return { + name: 'overflow', + async fn(state) { + const overflow = await detectOverflow(state) + let overWidth = 0 + if (overflow.left > 0) overWidth = overflow.left + if (overflow.right > 0) overWidth = overflow.right + const floatingWidth = state.rects.floating.width + return { + data: { + maxWidth: floatingWidth - overWidth, + }, + } + }, + } +} diff --git a/packages/components/tour/src/mask.ts b/packages/components/tour/src/mask.ts new file mode 100644 index 0000000000000..f4f33ee376c5e --- /dev/null +++ b/packages/components/tour/src/mask.ts @@ -0,0 +1,39 @@ +import { buildProps, definePropType } from '@element-plus/utils' +import type { ExtractPropTypes } from 'vue' +import type { PosInfo } from './types' + +export const maskProps = buildProps({ + /** + * @description mask's zIndex + */ + zIndex: { + type: Number, + default: 1001, + }, + /** + * @description whether to show the mask + */ + visible: Boolean, + /** + * @description mask's fill + */ + fill: { + type: String, + default: 'rgba(0,0,0,0.5)', + }, + /*** + * @description mask's transparent space position + */ + pos: { + type: definePropType(Object), + }, + /** + * @description whether the target element can be clickable, when using mask + */ + targetAreaClickable: { + type: Boolean, + default: true, + }, +}) + +export type MaskProps = ExtractPropTypes diff --git a/packages/components/tour/src/mask.vue b/packages/components/tour/src/mask.vue new file mode 100644 index 0000000000000..6a96687556ebd --- /dev/null +++ b/packages/components/tour/src/mask.vue @@ -0,0 +1,99 @@ + + + diff --git a/packages/components/tour/src/step.ts b/packages/components/tour/src/step.ts new file mode 100644 index 0000000000000..b683b0ddbc997 --- /dev/null +++ b/packages/components/tour/src/step.ts @@ -0,0 +1,88 @@ +import { buildProps, definePropType, iconPropType } from '@element-plus/utils' +import { tourContentProps } from './content' +import type { CSSProperties, ExtractPropTypes } from 'vue' +import type { TourBtnProps, TourMask } from './types' + +export const tourStepProps = buildProps({ + /** + * @description get the element the guide card points to. empty makes it show in center of screen + */ + target: { + type: definePropType(Object), + }, + /** + * @description the title of the tour content + */ + title: String, + /** + * @description description + */ + description: String, + /** + * @description whether to show a close button + */ + showClose: { + type: Boolean, + default: true, + }, + /** + * @description custom close icon, default is Close + */ + closeIcon: { + type: iconPropType, + }, + /** + * @description whether to show the arrow + */ + showArrow: { + type: Boolean, + default: true, + }, + /** + * @description position of the guide card relative to the target element + */ + placement: tourContentProps.placement, + /** + * @description whether to enable masking, change mask style and fill color by pass custom props + */ + mask: { + type: definePropType([Boolean, Object]), + }, + /** + * @description custom style for content + */ + contentStyle: { + type: definePropType([Object]), + }, + /** + * @description properties of the previous button + */ + prevButtonProps: { + type: definePropType(Object), + }, + /** + * @description properties of the Next button + */ + nextButtonProps: { + type: definePropType(Object), + }, + /** + * @description support pass custom scrollIntoView options + */ + scrollIntoViewOptions: { + type: definePropType([Boolean, Object]), + }, + /** + * @description type, affects the background color and text color + */ + type: { + type: definePropType<'defalut' | 'primary'>(String), + }, +}) + +export type TourStepProps = ExtractPropTypes + +export const tourStepEmits = { + close: () => true, +} +export type TourStepEmits = typeof tourStepEmits diff --git a/packages/components/tour/src/step.vue b/packages/components/tour/src/step.vue new file mode 100644 index 0000000000000..e7fd7fba6614b --- /dev/null +++ b/packages/components/tour/src/step.vue @@ -0,0 +1,137 @@ + + + diff --git a/packages/components/tour/src/steps.ts b/packages/components/tour/src/steps.ts new file mode 100644 index 0000000000000..114f2d9e15186 --- /dev/null +++ b/packages/components/tour/src/steps.ts @@ -0,0 +1,52 @@ +import { defineComponent } from 'vue' +import { flattedChildren, isArray } from '@element-plus/utils' +import { getNormalizedProps, isSameSteps } from './helper' +import type { FlattenVNodes } from '@element-plus/utils' +import type { Component, VNode } from 'vue' + +export default defineComponent({ + name: 'ElTourSteps', + props: { + current: { + type: Number, + default: 0, + }, + }, + emits: ['update-steps'], + setup(props, { slots, emit }) { + let cachedSteps: any[] = [] + + return () => { + const children = slots.default?.()! + const filteredSteps: any[] = [] + const result: VNode[] = [] + + function filterSteps(children?: FlattenVNodes) { + if (!isArray(children)) return + ;(children as VNode[]).forEach((item) => { + const name = ((item?.type || {}) as Component)?.name + + if (name === 'ElTourStep') { + const booleanKeys = ['showArrow', 'mask', 'scrollIntoViewOptions'] + filteredSteps.push(getNormalizedProps(item, booleanKeys)) + result.push(item) + } + }) + } + + if (children.length) { + filterSteps(flattedChildren(children![0]?.children)) + } + + if (!isSameSteps(filteredSteps, cachedSteps)) { + cachedSteps = filteredSteps + emit('update-steps', filteredSteps) + } + + if (result.length) { + return result[props.current] + } + return null + } + }, +}) diff --git a/packages/components/tour/src/tour.ts b/packages/components/tour/src/tour.ts new file mode 100644 index 0000000000000..77e94ad6a8d3f --- /dev/null +++ b/packages/components/tour/src/tour.ts @@ -0,0 +1,127 @@ +import { + buildProps, + definePropType, + iconPropType, + isBoolean, + isNumber, +} from '@element-plus/utils' +import { UPDATE_MODEL_EVENT } from '@element-plus/constants' +import { tourContentProps } from './content' +import type { CSSProperties, ExtractPropTypes } from 'vue' +import type Tour from './tour.vue' +import type { TourGap, TourMask } from './types' + +export const tourProps = buildProps({ + /** + * @description open tour + */ + modelValue: Boolean, + /** + * @description what is the current step + */ + current: { + type: Number, + default: 0, + }, + /** + * @description whether to show the arrow + */ + showArrow: { + type: Boolean, + default: true, + }, + /** + * @description whether to show a close button + */ + showClose: { + type: Boolean, + default: true, + }, + /** + * @description custom close icon + */ + closeIcon: { + type: iconPropType, + }, + /** + * @description position of the guide card relative to the target element + */ + placement: tourContentProps.placement, + /** + * @description custom style for content + */ + contentStyle: { + type: definePropType([Object]), + }, + /** + * @description whether to enable masking, change mask style and fill color by pass custom props + */ + mask: { + type: definePropType([Boolean, Object]), + default: true, + }, + /** + * @description transparent gap between mask and target + */ + gap: { + type: definePropType(Object), + default: () => ({ + offset: 6, + radius: 2, + }), + }, + /** + * @description tour's zIndex + */ + zIndex: { + type: Number, + }, + /** + * @description support pass custom scrollIntoView options + */ + scrollIntoViewOptions: { + type: definePropType([Boolean, Object]), + default: () => ({ + block: 'center', + }), + }, + /** + * @description type, affects the background color and text color + */ + type: { + type: definePropType<'defalut' | 'primary'>(String), + }, + /** + * @description which element the TourContent appends to + */ + appendTo: { + type: definePropType([String, Object]), + default: 'body', + }, + /** + * @description whether the Tour can be closed by pressing ESC + */ + closeOnPressEscape: { + type: Boolean, + default: true, + }, + /** + * @description whether the target element can be clickable, when using mask + */ + targetAreaClickable: { + type: Boolean, + default: true, + }, +}) + +export type TourProps = ExtractPropTypes +export type TourInstance = InstanceType + +export const tourEmits = { + [UPDATE_MODEL_EVENT]: (value: boolean) => isBoolean(value), + ['update:current']: (current: number) => isNumber(current), + close: (current: number) => isNumber(current), + finish: () => true, + change: (current: number) => isNumber(current), +} +export type TourEmits = typeof tourEmits diff --git a/packages/components/tour/src/tour.vue b/packages/components/tour/src/tour.vue new file mode 100644 index 0000000000000..00bc9a1b36417 --- /dev/null +++ b/packages/components/tour/src/tour.vue @@ -0,0 +1,146 @@ + + + diff --git a/packages/components/tour/src/types.ts b/packages/components/tour/src/types.ts new file mode 100644 index 0000000000000..ed219a92fe35f --- /dev/null +++ b/packages/components/tour/src/types.ts @@ -0,0 +1,39 @@ +import type { CSSProperties, VNode } from 'vue' +import type { Placement } from '@floating-ui/dom' + +export type TourMask = + | boolean + | { + style?: CSSProperties + color?: string + } + +export interface TourGap { + offset?: number | [number, number] + radius?: number +} + +export interface TourBtnProps { + children: VNode | string + onClick: () => void + className?: string + style?: CSSProperties +} + +export interface PosInfo { + left: number + top: number + height: number + width: number + radius: number +} + +export interface UsedTourStepProps { + target?: HTMLElement | null + showArrow?: boolean + placement?: Placement + contentStyle?: CSSProperties + mask?: TourMask + type?: 'default' | 'primary' + scrollIntoViewOptions?: boolean | ScrollIntoViewOptions +} diff --git a/packages/components/tour/style/css.ts b/packages/components/tour/style/css.ts new file mode 100644 index 0000000000000..d810bb566cfc1 --- /dev/null +++ b/packages/components/tour/style/css.ts @@ -0,0 +1,3 @@ +import '@element-plus/components/base/style/css' +import '@element-plus/components/button/style/css' +import '@element-plus/theme-chalk/el-tour.css' diff --git a/packages/components/tour/style/index.ts b/packages/components/tour/style/index.ts new file mode 100644 index 0000000000000..72ad676157e30 --- /dev/null +++ b/packages/components/tour/style/index.ts @@ -0,0 +1,3 @@ +import '@element-plus/components/base/style' +import '@element-plus/components/button/style' +import '@element-plus/theme-chalk/src/tour.scss' diff --git a/packages/element-plus/component.ts b/packages/element-plus/component.ts index 334aa45d18e31..4da41b2c0fa94 100644 --- a/packages/element-plus/component.ts +++ b/packages/element-plus/component.ts @@ -103,6 +103,7 @@ import { ElTreeSelect } from '@element-plus/components/tree-select' import { ElTreeV2 } from '@element-plus/components/tree-v2' import { ElUpload } from '@element-plus/components/upload' import { ElWatermark } from '@element-plus/components/watermark' +import { ElTour, ElTourStep } from '@element-plus/components/tour' import type { Plugin } from 'vue' @@ -206,4 +207,6 @@ export default [ ElTreeV2, ElUpload, ElWatermark, + ElTour, + ElTourStep, ] as Plugin[] diff --git a/packages/locale/lang/de.ts b/packages/locale/lang/de.ts index 4f262b3b3d0de..2210fc70d78ed 100644 --- a/packages/locale/lang/de.ts +++ b/packages/locale/lang/de.ts @@ -103,6 +103,11 @@ export default { clearFilter: 'Alles ', sumText: 'Summe', }, + tour: { + next: 'Weiter', + previous: 'Zurück', + finish: 'Fertig', + }, tree: { emptyText: 'Keine Einträge', }, diff --git a/packages/locale/lang/en.ts b/packages/locale/lang/en.ts index 1d342a8aba63e..e1e07995bfdeb 100644 --- a/packages/locale/lang/en.ts +++ b/packages/locale/lang/en.ts @@ -140,6 +140,11 @@ export default { clearFilter: 'All', sumText: 'Sum', }, + tour: { + next: 'Next', + previous: 'Previous', + finish: 'Finish', + }, tree: { emptyText: 'No Data', }, diff --git a/packages/locale/lang/eu.ts b/packages/locale/lang/eu.ts index 972a350c2b1e4..c543ae472b725 100644 --- a/packages/locale/lang/eu.ts +++ b/packages/locale/lang/eu.ts @@ -102,6 +102,11 @@ export default { clearFilter: 'Guztia', sumText: 'Batura', }, + tour: { + next: 'Hurrengoa', + previous: 'Aurrekoa', + finish: 'Bukatu', + }, tree: { emptyText: 'Daturik ez', }, diff --git a/packages/locale/lang/fa.ts b/packages/locale/lang/fa.ts index 63dfb83ce73d2..9089af6456f01 100644 --- a/packages/locale/lang/fa.ts +++ b/packages/locale/lang/fa.ts @@ -102,6 +102,11 @@ export default { clearFilter: 'همه', sumText: 'جمع', }, + tour: { + next: 'بعدی', + previous: 'قبلی', + finish: 'پایان', + }, tree: { emptyText: 'اطلاعاتی وجود ندارد', }, diff --git a/packages/locale/lang/ja.ts b/packages/locale/lang/ja.ts index 985e17f9c9508..cf00878adbfa3 100644 --- a/packages/locale/lang/ja.ts +++ b/packages/locale/lang/ja.ts @@ -102,6 +102,11 @@ export default { clearFilter: 'すべて', sumText: '合計', }, + tour: { + next: '次へ', + previous: '前へ', + finish: 'ツアー終了', + }, tree: { emptyText: 'データなし', }, diff --git a/packages/locale/lang/ko.ts b/packages/locale/lang/ko.ts index db75deeec1a5b..004f8e5a4eb9d 100644 --- a/packages/locale/lang/ko.ts +++ b/packages/locale/lang/ko.ts @@ -130,6 +130,11 @@ export default { clearFilter: '전체', sumText: '합계', }, + tour: { + next: '다음', + previous: '이전', + finish: '종료', + }, tree: { emptyText: '데이터 없음', }, diff --git a/packages/locale/lang/lt.ts b/packages/locale/lang/lt.ts index 2fa506e576886..12d7dace0079b 100644 --- a/packages/locale/lang/lt.ts +++ b/packages/locale/lang/lt.ts @@ -102,6 +102,11 @@ export default { clearFilter: 'Išvalyti', sumText: 'Suma', }, + tour: { + next: 'Kitas', + previous: 'Ankstesnis', + finish: 'Baigti', + }, tree: { emptyText: 'Nėra duomenų', }, diff --git a/packages/locale/lang/pl.ts b/packages/locale/lang/pl.ts index 61d7e295c251f..3a1e4d7ab0ef7 100644 --- a/packages/locale/lang/pl.ts +++ b/packages/locale/lang/pl.ts @@ -102,6 +102,11 @@ export default { clearFilter: 'Wszystko', sumText: 'Razem', }, + tour: { + next: 'Dalej', + previous: 'Wróć', + finish: 'Zakończ', + }, tree: { emptyText: 'Brak danych', }, diff --git a/packages/locale/lang/pt-br.ts b/packages/locale/lang/pt-br.ts index 9064a59f372cd..5a5a3a557ecf6 100644 --- a/packages/locale/lang/pt-br.ts +++ b/packages/locale/lang/pt-br.ts @@ -102,6 +102,11 @@ export default { clearFilter: 'Todos', sumText: 'Total', }, + tour: { + next: 'Próximo', + previous: 'Anterior', + finish: 'Finalizar', + }, tree: { emptyText: 'Sem dados', }, diff --git a/packages/locale/lang/ru.ts b/packages/locale/lang/ru.ts index 1b8ca83a5f5db..bf61b6a9ca2ed 100644 --- a/packages/locale/lang/ru.ts +++ b/packages/locale/lang/ru.ts @@ -102,6 +102,11 @@ export default { clearFilter: 'Все', sumText: 'Сумма', }, + tour: { + next: 'Далее', + previous: 'Назад', + finish: 'Завершить', + }, tree: { emptyText: 'Нет данных', }, diff --git a/packages/locale/lang/sv.ts b/packages/locale/lang/sv.ts index d9e1b6bb73f32..8ff9d4f94bfd4 100644 --- a/packages/locale/lang/sv.ts +++ b/packages/locale/lang/sv.ts @@ -102,6 +102,11 @@ export default { clearFilter: 'Alla', sumText: 'Summa', }, + tour: { + next: 'Nästa', + previous: 'Föregående', + finish: 'Avsluta', + }, tree: { emptyText: 'Ingen data', }, diff --git a/packages/locale/lang/th.ts b/packages/locale/lang/th.ts index 31143ffa5e7b2..b6cac8659e56e 100644 --- a/packages/locale/lang/th.ts +++ b/packages/locale/lang/th.ts @@ -102,6 +102,11 @@ export default { clearFilter: 'ทั้งหมด', sumText: 'รวม', }, + tour: { + next: 'ถัดไป', + previous: 'ย้อนกลับ', + finish: 'เสร็จสิ้น', + }, tree: { emptyText: 'ไม่พบข้อมูล', }, diff --git a/packages/locale/lang/uk.ts b/packages/locale/lang/uk.ts index ea65af8ccf04a..dd17f344ea797 100644 --- a/packages/locale/lang/uk.ts +++ b/packages/locale/lang/uk.ts @@ -102,6 +102,11 @@ export default { clearFilter: 'Все', sumText: 'Сума', }, + tour: { + next: 'Далі', + previous: 'Назад', + finish: 'Завершити', + }, tree: { emptyText: 'Немає даних', }, diff --git a/packages/locale/lang/vi.ts b/packages/locale/lang/vi.ts index b5c2363d87aba..caab5f8e1cead 100644 --- a/packages/locale/lang/vi.ts +++ b/packages/locale/lang/vi.ts @@ -102,6 +102,11 @@ export default { clearFilter: 'Xóa hết', sumText: 'Tổng', }, + tour: { + next: 'Tiếp', + previous: 'Trước', + finish: 'Hoàn thành', + }, tree: { emptyText: 'Không có dữ liệu', }, diff --git a/packages/locale/lang/zh-cn.ts b/packages/locale/lang/zh-cn.ts index 3e341acabb881..6d3854c56a0fc 100644 --- a/packages/locale/lang/zh-cn.ts +++ b/packages/locale/lang/zh-cn.ts @@ -104,6 +104,11 @@ export default { clearFilter: '全部', sumText: '合计', }, + tour: { + next: '下一步', + previous: '上一步', + finish: '结束导览', + }, tree: { emptyText: '暂无数据', }, diff --git a/packages/locale/lang/zh-tw.ts b/packages/locale/lang/zh-tw.ts index a6636ff10d278..ffc94d13f23fc 100644 --- a/packages/locale/lang/zh-tw.ts +++ b/packages/locale/lang/zh-tw.ts @@ -138,6 +138,11 @@ export default { clearFilter: '全部', sumText: '合計', }, + tour: { + next: '下一步', + previous: '上一步', + finish: '結束導覽', + }, tree: { emptyText: '暫無資料', }, diff --git a/packages/theme-chalk/src/common/var.scss b/packages/theme-chalk/src/common/var.scss index ee2d4dcd39bff..7fb8ef28aca5a 100644 --- a/packages/theme-chalk/src/common/var.scss +++ b/packages/theme-chalk/src/common/var.scss @@ -760,6 +760,26 @@ $dialog: map.merge( $dialog ); +// Tour +// css3 var in packages/theme-chalk/src/tour.scss +$tour: () !default; +$tour: map.merge( + ( + 'width': 520px, + 'padding-primary': 16px, + 'font-line-height': getCssVar('font-line-height-primary'), + 'title-font-size': 16px, + 'title-text-color': getCssVar('text-color-primary'), + 'title-font-weight': 400, + 'font-size': 14px, + 'color': getCssVar('text-color-primary'), + 'bg-color': getCssVar('bg-color'), + 'border-radius': 4px, + 'border-color': getCssVar('border-color-lighter'), + ), + $tour +); + // Table // css3 var in packages/theme-chalk/src/table.scss $table: () !default; diff --git a/packages/theme-chalk/src/index.scss b/packages/theme-chalk/src/index.scss index 0aabebb73a74a..8af6bdb8f0248 100644 --- a/packages/theme-chalk/src/index.scss +++ b/packages/theme-chalk/src/index.scss @@ -104,3 +104,4 @@ @use './option-group.scss'; @use './option-item.scss'; @use './statistic.scss'; +@use './tour.scss'; diff --git a/packages/theme-chalk/src/tour.scss b/packages/theme-chalk/src/tour.scss new file mode 100644 index 0000000000000..773382cdaa219 --- /dev/null +++ b/packages/theme-chalk/src/tour.scss @@ -0,0 +1,166 @@ +@use 'sass:map'; + +@use 'mixins/mixins' as *; +@use 'mixins/var' as *; +@use 'common/var' as *; + +@include b(tour) { + @include set-component-css-var('tour', $tour); + + @include e(hollow) { + transition: all getCssVar('transition-duration') ease; + } + + @include e(content) { + padding: 0; + border-radius: getCssVar('tour-border-radius'); + border: 1px solid getCssVar('tour-border-color'); + width: var(#{getCssVarName('tour-width')}); + background: getCssVar('tour-bg-color'); + box-shadow: getCssVar('box-shadow-light'); + box-sizing: border-box; + + $content-selector: &; + + $sides: ( + 'top': 'bottom', + 'bottom': 'top', + 'left': 'right', + 'right': 'left', + ); + + @include e(arrow) { + position: absolute; + background: getCssVar('tour-bg-color'); + border: 1px solid getCssVar('tour-border-color'); + width: 10px; + height: 10px; + pointer-events: none; + transform: rotate(45deg); + box-sizing: border-box; + + @each $side, + $adjacency + in ('top': 'left', 'bottom': 'right', 'left': 'bottom', 'right': 'top') + { + #{$content-selector}[data-side^='#{$side}'] & { + border-#{$side}-color: transparent; + border-#{$adjacency}-color: transparent; + } + } + + @each $side, $opposite in $sides { + #{$content-selector}[data-side^='#{$side}'] & { + #{$opposite}: -5px; + } + } + } + + + @include e(closebtn) { + position: absolute; + top: 6px; + right: 0; + padding: 0; + width: 44px; + height: 44px; + background: transparent; + border: none; + outline: none; + cursor: pointer; + font-size: var( + #{getCssVarName('message-close-size')}, + map.get($message, 'close-size') + ); + + .#{$namespace}-tour__close { + color: getCssVar('tour-title-text-color'); + font-size: inherit; + } + + &:focus, + &:hover { + .#{$namespace}-tour__close { + color: getCssVar('color', 'primary'); + } + } + } + + @include e(header) { + padding: getCssVar('tour', 'padding-primary'); + padding-bottom: 10px; + margin-right: 16px; + } + + @include e(title) { + line-height: getCssVar('tour-font-line-height'); + font-size: getCssVar('tour-title-font-size'); + color: getCssVar('tour-title-text-color'); + font-weight: getCssVar('tour-title-font-weight'); + } + + @include e(body) { + padding: 0 getCssVar('tour-padding-primary'); + color: getCssVar('tour-text-color'); + font-size: getCssVar('tour-font-size'); + img, video { + max-width: 100%; + } + } + + @include e(footer) { + padding: getCssVar('tour-padding-primary'); + padding-top: 10px; + box-sizing: border-box; + display: flex; + justify-content: space-between; + } + + @include b(tour-indicators) { + display: inline-block; + flex: 1; + } + + @include b(tour-indicator) { + width: 6px; + height: 6px; + display: inline-block; + border-radius: 50%; + background: getCssVar('color', 'info-light-9'); + margin-right: 6px; + + @include when(active) { + background: getCssVar('color', 'primary'); + } + } + } + + &.#{$namespace}-tour--primary { + @include set-css-var-value('tour-title-text-color',#fff); + @include set-css-var-value('tour-text-color',#fff); + @include set-css-var-value('tour-bg-color', getCssVar('color', 'primary')); + + .#{$namespace}-button--default { + color: getCssVar('color', 'primary'); + border-color: getCssVar('color', 'primary'); + background: #fff; + } + + .#{$namespace}-button--primary { + border-color: #fff; + } + + @include b(tour-indicator) { + background: rgba(255, 255, 255, 0.15); + @include when(active) { + background: #fff; + } + } + } +} + +@include b(tour-parent) { + @include m(hidden) { + overflow: hidden; + } +} \ No newline at end of file diff --git a/typings/components.d.ts b/typings/components.d.ts index 225d56e108214..1831026593678 100644 --- a/typings/components.d.ts +++ b/typings/components.d.ts @@ -98,6 +98,8 @@ declare module '@vue/runtime-core' { ElResult: typeof import('../packages/element-plus')['ElResult'] ElSelectV2: typeof import('../packages/element-plus')['ElSelectV2'] ElWatermark: typeof import('../packages/element-plus')['ElWatermark'] + ElTour: typeof import('../packages/element-plus')['ElTour'] + ElTourStep: typeof import('../packages/element-plus')['ElTourStep'] } interface ComponentCustomProperties {