diff --git a/components/_util/hooks/useId.ts b/components/_util/hooks/useId.ts
new file mode 100644
index 000000000..fea54f908
--- /dev/null
+++ b/components/_util/hooks/useId.ts
@@ -0,0 +1,30 @@
+import { ref } from 'vue';
+import canUseDom from '../../_util/canUseDom';
+
+let uuid = 0;
+
+/** Is client side and not jsdom */
+export const isBrowserClient = process.env.NODE_ENV !== 'test' && canUseDom();
+
+/** Get unique id for accessibility usage */
+export function getUUID(): number | string {
+ let retId: string | number;
+
+ // Test never reach
+ /* istanbul ignore if */
+ if (isBrowserClient) {
+ retId = uuid;
+ uuid += 1;
+ } else {
+ retId = 'TEST_OR_SSR';
+ }
+
+ return retId;
+}
+
+export default function useId(id = ref('')) {
+ // Inner id for accessibility usage. Only work in client side
+ const innerId = `vc_unique_${getUUID()}`;
+
+ return id.value || innerId;
+}
diff --git a/components/components.ts b/components/components.ts
index d6b1e6d80..f9db584a6 100644
--- a/components/components.ts
+++ b/components/components.ts
@@ -258,3 +258,6 @@ export { default as Segmented } from './segmented';
export type { QRCodeProps } from './qrcode';
export { default as QRCode } from './qrcode';
+
+export type { TourProps, TourStepProps } from './tour';
+export { default as Tour } from './tour';
diff --git a/components/locale/en_US.ts b/components/locale/en_US.ts
index 1038f45df..ae0af4a55 100644
--- a/components/locale/en_US.ts
+++ b/components/locale/en_US.ts
@@ -35,6 +35,11 @@ const localeValues: Locale = {
triggerAsc: 'Click to sort ascending',
cancelSort: 'Click to cancel sorting',
},
+ Tour: {
+ Next: 'Next',
+ Previous: 'Previous',
+ Finish: 'Finish',
+ },
Modal: {
okText: 'OK',
cancelText: 'Cancel',
diff --git a/components/locale/index.tsx b/components/locale/index.tsx
index 0f7a8c59f..2985422d7 100644
--- a/components/locale/index.tsx
+++ b/components/locale/index.tsx
@@ -10,6 +10,7 @@ import type { PickerLocale as DatePickerLocale } from '../date-picker/generatePi
import type { PaginationLocale } from '../pagination/Pagination';
import type { TableLocale } from '../table/interface';
import type { UploadLocale } from '../upload/interface';
+import type { TourLocale } from '../tour/interface';
interface TransferLocaleForEmpty {
description: string;
@@ -43,6 +44,7 @@ export interface Locale {
copied?: any;
expand?: any;
};
+ Tour?: TourLocale;
QRCode?: {
expired?: string;
refresh?: string;
diff --git a/components/locale/pt_BR.ts b/components/locale/pt_BR.ts
index ec779f0cb..99c5ceff2 100644
--- a/components/locale/pt_BR.ts
+++ b/components/locale/pt_BR.ts
@@ -33,6 +33,11 @@ const localeValues: Locale = {
triggerAsc: 'Clique organiza por ascendente',
cancelSort: 'Clique para cancelar organização',
},
+ Tour: {
+ Next: 'Próximo',
+ Previous: 'Anterior',
+ Finish: 'Finalizar',
+ },
Modal: {
okText: 'OK',
cancelText: 'Cancelar',
diff --git a/components/locale/zh_CN.ts b/components/locale/zh_CN.ts
index 65e601980..3455f4f08 100644
--- a/components/locale/zh_CN.ts
+++ b/components/locale/zh_CN.ts
@@ -35,6 +35,11 @@ const localeValues: Locale = {
triggerAsc: '点击升序',
cancelSort: '取消排序',
},
+ Tour: {
+ Next: '下一步',
+ Previous: '上一步',
+ Finish: '结束导览',
+ },
Modal: {
okText: '确定',
cancelText: '取消',
diff --git a/components/locale/zh_HK.ts b/components/locale/zh_HK.ts
index 17a491d5b..858719081 100644
--- a/components/locale/zh_HK.ts
+++ b/components/locale/zh_HK.ts
@@ -32,6 +32,11 @@ const localeValues: Locale = {
triggerAsc: '點擊升序',
cancelSort: '取消排序',
},
+ Tour: {
+ Next: '下一步',
+ Previous: '上一步',
+ Finish: '結束導覽',
+ },
Modal: {
okText: '確定',
cancelText: '取消',
diff --git a/components/locale/zh_TW.ts b/components/locale/zh_TW.ts
index 3100f87d4..fc639d4d4 100644
--- a/components/locale/zh_TW.ts
+++ b/components/locale/zh_TW.ts
@@ -32,6 +32,11 @@ const localeValues: Locale = {
triggerAsc: '點擊升序',
cancelSort: '取消排序',
},
+ Tour: {
+ Next: '下一步',
+ Previous: '上一步',
+ Finish: '結束導覽',
+ },
Modal: {
okText: '確定',
cancelText: '取消',
diff --git a/components/theme/interface/alias.ts b/components/theme/interface/alias.ts
index 50353a82e..bd6f228db 100644
--- a/components/theme/interface/alias.ts
+++ b/components/theme/interface/alias.ts
@@ -94,6 +94,7 @@ export interface AliasToken extends MapToken {
boxShadow: string;
boxShadowSecondary: string;
+ boxShadowTertiary: string;
linkDecoration: CSSProperties['textDecoration'];
linkHoverDecoration: CSSProperties['textDecoration'];
diff --git a/components/theme/interface/components.ts b/components/theme/interface/components.ts
index 8ac020484..92f2fffb8 100644
--- a/components/theme/interface/components.ts
+++ b/components/theme/interface/components.ts
@@ -44,7 +44,7 @@ import type { ComponentToken as TooltipComponentToken } from '../../tooltip/styl
import type { ComponentToken as TransferComponentToken } from '../../transfer/style';
import type { ComponentToken as TypographyComponentToken } from '../../typography/style';
import type { ComponentToken as UploadComponentToken } from '../../upload/style';
-// import type { ComponentToken as TourComponentToken } from '../../tour/style';
+import type { ComponentToken as TourComponentToken } from '../../tour/style';
import type { ComponentToken as QRCodeComponentToken } from '../../qrcode/style';
// import type { ComponentToken as AppComponentToken } from '../../app/style';
import type { ComponentToken as WaveToken } from '../../_util/wave/style';
@@ -110,7 +110,7 @@ export interface ComponentTokenMap {
Table?: TableComponentToken;
Space?: SpaceComponentToken;
Progress?: ProgressComponentToken;
- // Tour?: TourComponentToken;
+ Tour?: TourComponentToken;
QRCode?: QRCodeComponentToken;
// App?: AppComponentToken;
diff --git a/components/theme/util/alias.ts b/components/theme/util/alias.ts
index 91d2d4f6e..b886bd1db 100644
--- a/components/theme/util/alias.ts
+++ b/components/theme/util/alias.ts
@@ -134,6 +134,11 @@ export default function formatToken(derivativeToken: RawMergedToken): AliasToken
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05)
`,
+ boxShadowTertiary: `
+ 0 1px 2px 0 rgba(0, 0, 0, 0.03),
+ 0 1px 6px -1px rgba(0, 0, 0, 0.02),
+ 0 2px 4px 0 rgba(0, 0, 0, 0.02)
+ `,
screenXS,
screenXSMin: screenXS,
diff --git a/components/tour/__tests__/__snapshots__/demo-extend.test.ts.snap b/components/tour/__tests__/__snapshots__/demo-extend.test.ts.snap
new file mode 100644
index 000000000..6e4007551
--- /dev/null
+++ b/components/tour/__tests__/__snapshots__/demo-extend.test.ts.snap
@@ -0,0 +1,478 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders ./components/tour/demo/basic.tsx extend context correctly 1`] = `
+Array [
+ ,
+
,
+
+
+
+
+
+
+
+
+
,
+]
+`;
+
+exports[`renders ./components/tour/demo/non-modal.tsx extend context correctly 1`] = `
+Array [
+ ,
+ ,
+
+
+
+
+
+
+
+
+
,
+]
+`;
+
+exports[`renders ./components/tour/demo/placement.tsx extend context correctly 1`] = `
+
+`;
+
+exports[`renders ./components/tour/demo/render-panel.tsx extend context correctly 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Hello World?!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hello World?!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hello World?!
+
+
+
+
+
+
+
+
+`;
diff --git a/components/tour/__tests__/__snapshots__/demo.test.ts.snap b/components/tour/__tests__/__snapshots__/demo.test.ts.snap
new file mode 100644
index 000000000..4b5100299
--- /dev/null
+++ b/components/tour/__tests__/__snapshots__/demo.test.ts.snap
@@ -0,0 +1,478 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders ./components/tour/demo/basic.tsx correctly 1`] = `
+Array [
+ ,
+ ,
+
+
+
+
+
+
+
+
+
,
+]
+`;
+
+exports[`renders ./components/tour/demo/non-modal.tsx correctly 1`] = `
+Array [
+ ,
+ ,
+
+
+
+
+
+
+
+
+
,
+]
+`;
+
+exports[`renders ./components/tour/demo/placement.tsx correctly 1`] = `
+
+`;
+
+exports[`renders ./components/tour/demo/render-panel.tsx correctly 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Hello World?!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hello World?!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hello World?!
+
+
+
+
+
+
+
+
+`;
diff --git a/components/tour/__tests__/__snapshots__/index.test.tsx.snap b/components/tour/__tests__/__snapshots__/index.test.tsx.snap
new file mode 100644
index 000000000..43f7f3c83
--- /dev/null
+++ b/components/tour/__tests__/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,706 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Tour Primary 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ primary description.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Tour basic 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Tour button props onClick 1`] = `
+
+
+
+
+
+ finishButton
+
+
+
+
+
+
+`;
+
+exports[`Tour custom step pre btn & next btn className & style 1`] = `
+
+
+
+
+
+
+
+
+
+ Here is the content of Tour.
+
+
+
+
+
+
+`;
+
+exports[`Tour rtl render component should be rendered correctly in RTL direction 1`] = `null`;
+
+exports[`Tour single 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ cover description.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Tour step support Primary 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ primary description.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Tour steps is empty 1`] = `
+
+
+
+
+
+
+`;
+
diff --git a/components/tour/__tests__/demo-extend.test.ts b/components/tour/__tests__/demo-extend.test.ts
new file mode 100644
index 000000000..f8acbed23
--- /dev/null
+++ b/components/tour/__tests__/demo-extend.test.ts
@@ -0,0 +1,3 @@
+import { extendTest } from '../../../tests/shared/demoTest';
+
+extendTest('tour');
diff --git a/components/tour/__tests__/demo.test.ts b/components/tour/__tests__/demo.test.ts
new file mode 100644
index 000000000..1e1d4eb49
--- /dev/null
+++ b/components/tour/__tests__/demo.test.ts
@@ -0,0 +1,3 @@
+import demoTest from '../../../tests/shared/demoTest';
+
+demoTest('tour');
diff --git a/components/tour/__tests__/image.test.ts b/components/tour/__tests__/image.test.ts
new file mode 100644
index 000000000..4268c7ab9
--- /dev/null
+++ b/components/tour/__tests__/image.test.ts
@@ -0,0 +1,5 @@
+import { imageDemoTest } from '../../../tests/shared/imageTest';
+
+describe('Tooltip tour', () => {
+ imageDemoTest('tour');
+});
diff --git a/components/tour/__tests__/index.test.tsx b/components/tour/__tests__/index.test.tsx
new file mode 100644
index 000000000..4f5e192e7
--- /dev/null
+++ b/components/tour/__tests__/index.test.tsx
@@ -0,0 +1,287 @@
+import React, { useRef, useEffect } from 'react';
+import Tour from '..';
+import mountTest from '../../../tests/shared/mountTest';
+import rtlTest from '../../../tests/shared/rtlTest';
+import { fireEvent, render, screen } from '../../../tests/utils';
+import panelRender from '../panelRender';
+
+describe('Tour', () => {
+ mountTest(Tour);
+ rtlTest(Tour);
+
+ it('single', () => {
+ const App: React.FC = () => {
+ const coverBtnRef = useRef(null);
+ return (
+ <>
+
+ coverBtnRef.current!,
+ },
+ ]}
+ />
+ >
+ );
+ };
+ const { getByText, baseElement } = render();
+ expect(getByText('cover title')).toBeTruthy();
+ expect(getByText('cover description.')).toBeTruthy();
+ expect(baseElement).toMatchSnapshot();
+ });
+
+ it('steps is empty', () => {
+ const App: React.FC = () => {
+ const coverBtnRef = useRef(null);
+ return (
+ <>
+
+
+
+ >
+ );
+ };
+ const { baseElement } = render();
+ expect(baseElement).toMatchSnapshot();
+ });
+
+ it('button props onClick', () => {
+ const App: React.FC = () => {
+ const coverBtnRef = useRef(null);
+ const [btnName, steBtnName] = React.useState('defaultBtn');
+ return (
+ <>
+ {btnName}
+
+
+ coverBtnRef.current!,
+ nextButtonProps: {
+ onClick: () => steBtnName('nextButton'),
+ },
+ },
+ {
+ title: '',
+ target: () => coverBtnRef.current!,
+ prevButtonProps: {
+ onClick: () => steBtnName('prevButton'),
+ },
+ nextButtonProps: {
+ onClick: () => steBtnName('finishButton'),
+ },
+ },
+ ]}
+ />
+ >
+ );
+ };
+ const { baseElement } = render();
+ expect(baseElement.querySelector('#btnName')).toHaveTextContent('defaultBtn');
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }));
+ expect(baseElement.querySelector('#btnName')).toHaveTextContent('nextButton');
+ fireEvent.click(screen.getByRole('button', { name: 'Previous' }));
+ expect(baseElement.querySelector('#btnName')).toHaveTextContent('prevButton');
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Finish' }));
+ expect(baseElement.querySelector('#btnName')).toHaveTextContent('finishButton');
+ expect(baseElement).toMatchSnapshot();
+ });
+
+ it('Primary', () => {
+ const App: React.FC = () => {
+ const coverBtnRef = useRef(null);
+ return (
+ <>
+
+
+ coverBtnRef.current!,
+ },
+ ]}
+ />
+ >
+ );
+ };
+ const { getByText, baseElement } = render();
+ expect(getByText('primary description.')).toBeTruthy();
+ expect(baseElement.querySelector('.ant-tour-content')).toHaveClass('ant-tour-primary');
+ expect(baseElement).toMatchSnapshot();
+ });
+
+ it('step support Primary', () => {
+ const App: React.FC = () => {
+ const coverBtnRef = useRef(null);
+ return (
+ <>
+
+
+ coverBtnRef.current!,
+ },
+ {
+ title: 'primary title',
+ description: 'primary description.',
+ target: () => coverBtnRef.current!,
+ type: 'primary',
+ },
+ ]}
+ />
+ >
+ );
+ };
+ const { getByText, container, baseElement } = render();
+ expect(getByText('cover description.')).toBeTruthy();
+ expect(container.querySelector('.ant-tour-content.ant-tour-primary')).toBeFalsy();
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }));
+ expect(getByText('primary description.')).toBeTruthy();
+ expect(container.querySelector('.ant-tour-content.ant-tour-primary')).toBeTruthy();
+ expect(baseElement).toMatchSnapshot();
+ });
+
+ it('basic', () => {
+ const App: React.FC = () => {
+ const coverBtnRef = useRef(null);
+ const placementBtnRef = useRef(null);
+
+ const [show, setShow] = React.useState();
+
+ useEffect(() => {
+ if (show === false) {
+ setShow(true);
+ }
+ }, [show]);
+
+ return (
+ <>
+
+
+
+
+
+
+ {show && (
+ coverBtnRef.current!,
+ cover: (
+
+ ),
+ },
+ {
+ title: 'Adjust Placement',
+ description: 'Here is the content of Tour which show on the right.',
+ placement: 'right',
+ target: () => placementBtnRef.current!,
+ },
+ ]}
+ />
+ )}
+ >
+ );
+ };
+ const { getByText, container, baseElement } = render();
+ fireEvent.click(screen.getByRole('button', { name: 'Show' }));
+ expect(getByText('Show in Center')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }));
+ expect(getByText('Here is the content of Tour.')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }));
+ expect(getByText('Adjust Placement')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'Finish' }));
+ expect(container.querySelector('.ant-tour')).toBeFalsy();
+ expect(baseElement).toMatchSnapshot();
+ });
+ it('panelRender should correct render when total is undefined', () => {
+ expect(() => {
+ panelRender({ total: undefined, title: test
}, 0, 'default');
+ }).not.toThrow();
+ });
+
+ it('custom step pre btn & next btn className & style', () => {
+ const App: React.FC = () => (
+
+ ),
+ },
+ ]}
+ />
+ );
+
+ const { container } = render();
+ // className
+ expect(
+ screen.getByRole('button', { name: 'Next' }).className.includes('customClassName'),
+ ).toEqual(true);
+ // style
+ expect(screen.getByRole('button', { name: 'Next' }).style.backgroundColor).toEqual(
+ 'rgb(69, 69, 255)',
+ );
+ expect(container.firstChild).toMatchSnapshot();
+ });
+});
diff --git a/components/tour/demo/basic.vue b/components/tour/demo/basic.vue
new file mode 100644
index 000000000..020cb8a3e
--- /dev/null
+++ b/components/tour/demo/basic.vue
@@ -0,0 +1,70 @@
+
+---
+order: 0
+title:
+ zh-CN: 基本用法
+ en-US: Basic usage
+---
+
+## zh-CN
+
+最简单的用法。
+
+## en-US
+
+The most basic usage.
+
+
+
+
+
+ Begin Tour
+
+
+
+
+ Upload
+ Save
+
+
+
+
+
+
+
diff --git a/components/tour/demo/index.vue b/components/tour/demo/index.vue
new file mode 100644
index 000000000..a684006e2
--- /dev/null
+++ b/components/tour/demo/index.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/components/tour/demo/indicator.vue b/components/tour/demo/indicator.vue
new file mode 100644
index 000000000..bdddb70a7
--- /dev/null
+++ b/components/tour/demo/indicator.vue
@@ -0,0 +1,70 @@
+
+---
+order: 4
+title:
+ zh-CN: 自定义指示器
+ en-US: custom indicator
+---
+
+## zh-CN
+
+自定义指示器。
+
+## en-US
+
+Custom indicator.
+
+
+
+
+
+ Begin Tour
+
+
+
+
+ Upload
+ Save
+
+
+
+
+
+ {{ current + 1 }} / {{ total }}
+
+
+
+
+
diff --git a/components/tour/demo/mask.vue b/components/tour/demo/mask.vue
new file mode 100644
index 000000000..1d4f6ef1b
--- /dev/null
+++ b/components/tour/demo/mask.vue
@@ -0,0 +1,86 @@
+
+---
+order: 3
+title:
+ zh-CN: 自定义遮罩样式
+ en-US: custom mask style
+---
+
+## zh-CN
+
+自定义遮罩样式。
+
+## en-US
+
+custom mask style.
+
+
+
+
+
+ Begin Tour
+
+
+
+
+ Upload
+ Save
+
+
+
+
+
+
+
diff --git a/components/tour/demo/non-modal.vue b/components/tour/demo/non-modal.vue
new file mode 100644
index 000000000..9229d956d
--- /dev/null
+++ b/components/tour/demo/non-modal.vue
@@ -0,0 +1,69 @@
+
+---
+order: 1
+title:
+ zh-CN: 非模态
+ en-US: Non modal
+---
+
+## zh-CN
+
+使用 `mask={false}` 可以将引导变为非模态,同时为了强调引导本身,建议与 `type="primary"` 组合使用。
+
+## en-US
+
+Use `mask={false}` to make Tour non-modal. At the meantime it is recommended to use with `type="primary"` to emphasize the guide itself.
+
+
+
+
+
+ Begin Tour
+
+
+
+
+ Upload
+ Save
+
+
+
+
+
+
+
diff --git a/components/tour/demo/placement.vue b/components/tour/demo/placement.vue
new file mode 100644
index 000000000..864db82e4
--- /dev/null
+++ b/components/tour/demo/placement.vue
@@ -0,0 +1,57 @@
+
+---
+order: 2
+title:
+ zh-CN: 位置
+ en-US: Placement
+---
+
+## zh-CN
+
+改变引导相对于目标的位置,共有 12 种位置可供选择。当 `target={null}` 时引导将会展示在正中央。
+
+## en-US
+
+Change the placement of the guide relative to the target, there are 12 placements available. When `target={null}` the guide will show in the center.
+
+
+
+
+
+ Begin Tour
+
+
+
+
+
diff --git a/components/tour/index.en-US.md b/components/tour/index.en-US.md
new file mode 100644
index 000000000..67115f406
--- /dev/null
+++ b/components/tour/index.en-US.md
@@ -0,0 +1,59 @@
+---
+category: Components
+type: Data Display
+title: Tour
+cover: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*8CC_Tbe3_e4AAAAAAAAAAAAADrJ8AQ/original
+coverDark: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*nF6hQpM0XtEAAAAAAAAAAAAADrJ8AQ/original
+---
+
+A popup component for guiding users through a product. Available since `4.0.0`.
+
+## When To Use
+
+Use when you want to guide users through a product.
+
+## API
+
+### Tour
+
+| Property | Description | Type | Default | Version |
+| --- | --- | --- | --- | --- |
+| arrow | Whether to show the arrow, including the configuration whether to point to the center of the element | `boolean`\|`{ pointAtCenter: boolean}` | `true` | |
+| placement | Position of the guide card relative to the target element | `left` `leftTop` `leftBottom` `right` `rightTop` `rightBottom` `top` `topLeft` `topRight` `bottom` `bottomLeft` `bottomRight` | `bottom` | |
+| mask | Whether to enable masking, change mask style and fill color by pass custom props | `boolean` \| `{ style?: CSSProperties; color?: string; }` | `true` | |
+| type | Type, affects the background color and text color | `default` `primary` | `default` | |
+| open | Open tour | `boolean` | - | |
+| current | What is the current step | `number` | - | |
+| scrollIntoViewOptions | support pass custom scrollIntoView options | `boolean` \| `ScrollIntoViewOptions` | `true` | |
+| indicatorsRender | custom indicator | `v-slot:indicatorsRender="{current, total}"` | - | |
+| zIndex | Tour's zIndex | `number` | `1001` | |
+
+### Tour events
+
+| Events Name | Description | Arguments | Version |
+| --- | --- | --- | --- | --- |
+| close | Callback function on shutdown | `Function` | - | |
+| finish | Callback function on finished | `Function` | - | |
+| change | Callback when the step changes. Current is the previous step | `(current: number) => void` |
+
+### TourStep
+
+| Property | Description | Type | Default | Version |
+| --- | --- | --- | --- | --- |
+| target | Get the element the guide card points to. Empty makes it show in center of screen | `() => HTMLElement` `HTMLElement` | - | |
+| arrow | Whether to show the arrow, including the configuration whether to point to the center of the element | `boolean` `{ pointAtCenter: boolean}` | `true` | |
+| cover | Displayed pictures or videos | `VueNode` | - | |
+| title | title | `VueNode` | - | |
+| description | description | `VueNode` | - | |
+| placement | Position of the guide card relative to the target element | `left` `leftTop` `leftBottom` `right` `rightTop` `rightBottom` `top` `topLeft` `topRight` `bottom` `bottomLeft` `bottomRight` | `bottom` | |
+| mask | Whether to enable masking, change mask style and fill color by pass custom props, the default follows the `mask` property of Tour | `boolean` \| `{ style?: CSSProperties; color?: string; }` | `true` | |
+| type | Type, affects the background color and text color | `default` `primary` | `default` | |
+| nextButtonProps | Properties of the Next button | `{ children: VueNode; onClick: Function }` | - | |
+| prevButtonProps | Properties of the previous button | `{ children: VueNode; onClick: Function }` | - | |
+| scrollIntoViewOptions | support pass custom scrollIntoView options, the default follows the `scrollIntoViewOptions` property of Tour | `boolean` \| `ScrollIntoViewOptions` | `true` | |
+
+### TourStep events
+
+| Events Name | Description | Arguments | Version |
+| ----------- | ----------------------------- | ---------- | ------- | --- |
+| close | Callback function on shutdown | `Function` | - | |
diff --git a/components/tour/index.tsx b/components/tour/index.tsx
new file mode 100644
index 000000000..c6648e4ae
--- /dev/null
+++ b/components/tour/index.tsx
@@ -0,0 +1,82 @@
+import { defineComponent, toRefs } from 'vue';
+import VCTour from '../vc-tour';
+import classNames from '../_util/classNames';
+import TourPanel from './panelRender';
+import type { TourProps, TourStepProps } from './interface';
+import { tourProps } from './interface';
+import useConfigInject from '../config-provider/hooks/useConfigInject';
+import type { VueNode } from '../_util/type';
+import { withInstall } from '../_util/type';
+import useMergedType from './useMergedType';
+
+// CSSINJS
+import useStyle from './style';
+
+export { TourProps, TourStepProps };
+
+const Tour = defineComponent({
+ name: 'ATour',
+ props: tourProps(),
+ setup(props, { attrs, emit, slots }) {
+ const { current } = toRefs(props);
+ const { prefixCls, direction } = useConfigInject('tour', props);
+
+ // style
+ const [wrapSSR, hashId] = useStyle(prefixCls);
+
+ const { currentMergedType, updateInnerCurrent } = useMergedType({
+ defaultType: props.type,
+ steps: props.steps,
+ current,
+ defaultCurrent: props.defaultCurrent,
+ });
+
+ return () => {
+ const { steps, current, type, rootClassName, ...restProps } = props;
+
+ const customClassName = classNames(
+ {
+ [`${prefixCls.value}-primary`]: currentMergedType.value === 'primary',
+ [`${prefixCls.value}-rtl`]: direction.value === 'rtl',
+ },
+ hashId.value,
+ rootClassName,
+ );
+
+ const mergedRenderPanel = (stepProps: TourStepProps, stepCurrent: number): VueNode => {
+ return (
+
+ );
+ };
+
+ const onStepChange = (stepCurrent: number) => {
+ updateInnerCurrent(stepCurrent);
+ emit('change', stepCurrent);
+ };
+
+ return wrapSSR(
+ ,
+ );
+ };
+ },
+});
+
+export default withInstall(Tour);
diff --git a/components/tour/index.zh-CN.md b/components/tour/index.zh-CN.md
new file mode 100644
index 000000000..874e8c0e0
--- /dev/null
+++ b/components/tour/index.zh-CN.md
@@ -0,0 +1,60 @@
+---
+category: Components
+type: 数据展示
+title: Tour
+subtitle: 漫游式引导
+cover: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*8CC_Tbe3_e4AAAAAAAAAAAAADrJ8AQ/original
+coverDark: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*nF6hQpM0XtEAAAAAAAAAAAAADrJ8AQ/original
+---
+
+用于分步引导用户了解产品功能的气泡组件。自 `4.0.0` 版本开始提供该组件。
+
+## 何时使用
+
+常用于引导用户了解产品功能。
+
+## API
+
+### Tour
+
+| 属性 | 说明 | 类型 | 默认值 | 版本 |
+| --- | --- | --- | --- | --- |
+| arrow | 是否显示箭头,包含是否指向元素中心的配置 | `boolean` \| `{ pointAtCenter: boolean}` | `true` | |
+| placement | 引导卡片相对于目标元素的位置 | `left` `leftTop` `leftBottom` `right` `rightTop` `rightBottom` `top` `topLeft` `topRight` `bottom` `bottomLeft` `bottomRight` | `bottom` | |
+| mask | 是否启用蒙层,也可传入配置改变蒙层样式和填充色 | `boolean` \| `{ style?: CSSProperties; color?: string; }` | `true` | |
+| type | 类型,影响底色与文字颜色 | `default` \| `primary` | `default` | |
+| open | 打开引导 | `boolean` | - | |
+| current | 当前处于哪一步 | `number` | - | |
+| scrollIntoViewOptions | 是否支持当前元素滚动到视窗内,也可传入配置指定滚动视窗的相关参数 | `boolean` \| `ScrollIntoViewOptions` | `true` | |
+| indicatorsRender | 自定义指示器 | `v-slot:indicatorsRender="{current, total}"` | - | |
+| zIndex | Tour 的层级 | `number` | `1001` | |
+
+### Tour events
+
+| 事件名称 | 说明 | 回调参数 | 版本 |
+| -------- | ---------------------------------------- | --------------------------- | ---- | --- |
+| close | 关闭引导时的回调函数 | `Function` | - | |
+| finish | 引导完成时的回调 | `Function` | - | |
+| change | 步骤改变时的回调,current 为当前前的步骤 | `(current: number) => void` | - | |
+
+### TourStep 引导步骤卡片
+
+| 属性 | 说明 | 类型 | 默认值 | 版本 |
+| --- | --- | --- | --- | --- |
+| target | 获取引导卡片指向的元素,为空时居中于屏幕 | `() => HTMLElement` \| `HTMLElement` | - | |
+| arrow | 是否显示箭头,包含是否指向元素中心的配置 | `boolean` \| `{ pointAtCenter: boolean}` | `true` | |
+| cover | 展示的图片或者视频 | `VueNode` | - | |
+| title | 标题 | `VueNode` | - | |
+| description | 主要描述部分 | `VueNode` | - | |
+| placement | 引导卡片相对于目标元素的位置 | `left` `leftTop` `leftBottom` `right` `rightTop` `rightBottom` `top` `topLeft` `topRight` `bottom` `bottomLeft` `bottomRight` `bottom` | | |
+| mask | 是否启用蒙层,也可传入配置改变蒙层样式和填充色,默认跟随 Tour 的 `mask` 属性 | `boolean` \| `{ style?: CSSProperties; color?: string; }` | `true` | |
+| type | 类型,影响底色与文字颜色 | `default` \| `primary` | `default` | |
+| nextButtonProps | 下一步按钮的属性 | `{ children: VueNode; onClick: Function }` | - | |
+| prevButtonProps | 上一步按钮的属性 | `{ children: VueNode; onClick: Function }` | - | |
+| scrollIntoViewOptions | 是否支持当前元素滚动到视窗内,也可传入配置指定滚动视窗的相关参数,默认跟随 Tour 的 `scrollIntoViewOptions` 属性 | `boolean` \| `ScrollIntoViewOptions` | `true` | |
+
+### TourStep events
+
+| 事件名称 | 说明 | 回调参数 | 版本 |
+| -------- | -------------------- | ---------- | ---- | --- |
+| close | 关闭引导时的回调函数 | `Function` | - | |
diff --git a/components/tour/interface.ts b/components/tour/interface.ts
new file mode 100644
index 000000000..d915dc239
--- /dev/null
+++ b/components/tour/interface.ts
@@ -0,0 +1,41 @@
+import type { CSSProperties, ExtractPropTypes, PropType } from 'vue';
+import { tourProps as VCTourProps, tourStepProps as VCTourStepProps } from '../vc-tour';
+import type { VueNode } from '../_util/type';
+
+export const tourProps = () => ({
+ ...VCTourProps(),
+ steps: { type: Array as PropType },
+ prefixCls: { type: String },
+ current: { type: Number },
+ type: { type: String as PropType<'default' | 'primary'> }, // default 类型,影响底色与文字颜色
+});
+
+export type TourProps = Partial>>;
+
+export interface TourBtnProps {
+ children?: () => VueNode;
+ onClick?: () => void;
+ className?: string;
+ style?: CSSProperties;
+}
+
+export const tourStepProps = () => ({
+ ...VCTourStepProps(),
+ cover: { type: Object as PropType }, // 展示的图片或者视频
+ nextButtonProps: {
+ type: Object as PropType,
+ },
+ prevButtonProps: {
+ type: Object as PropType,
+ },
+ current: { type: Number },
+ type: { type: String as PropType<'default' | 'primary'> }, // default 类型,影响底色与文字颜色
+});
+
+export type TourStepProps = Partial>>;
+
+export interface TourLocale {
+ Next: string;
+ Previous: string;
+ Finish: string;
+}
diff --git a/components/tour/panelRender.tsx b/components/tour/panelRender.tsx
new file mode 100644
index 000000000..bf3fe9cfd
--- /dev/null
+++ b/components/tour/panelRender.tsx
@@ -0,0 +1,154 @@
+import { computed, defineComponent, toRefs } from 'vue';
+import classNames from '../_util/classNames';
+import CloseOutlined from '@ant-design/icons-vue/CloseOutlined';
+import { tourStepProps } from './interface';
+import type { TourBtnProps } from './interface';
+
+import LocaleReceiver from '../locale/LocaleReceiver';
+import Button from '../button';
+import type { ButtonProps } from '../button';
+import defaultLocale from '../locale/en_US';
+
+import type { VueNode } from '../_util/type';
+
+const panelRender = defineComponent({
+ props: tourStepProps(),
+ setup(props, { attrs, slots }) {
+ const { current, total } = toRefs(props);
+
+ const isLastStep = computed(() => current.value === total.value - 1);
+
+ const prevButtonProps = props.prevButtonProps as TourBtnProps;
+ const nextButtonProps = props.nextButtonProps as TourBtnProps;
+
+ const prevBtnClick = e => {
+ props.onPrev?.(e);
+ if (typeof prevButtonProps?.onClick === 'function') {
+ prevButtonProps?.onClick();
+ }
+ };
+
+ const nextBtnClick = e => {
+ if (isLastStep.value) {
+ props.onFinish?.(e);
+ } else {
+ props.onNext?.(e);
+ }
+ if (typeof nextButtonProps?.onClick === 'function') {
+ nextButtonProps?.onClick();
+ }
+ };
+
+ return () => {
+ const {
+ prefixCls,
+ title,
+ onClose,
+
+ cover,
+ description,
+ type: stepType,
+ arrow,
+ } = props;
+
+ const prevButtonProps = props.prevButtonProps as TourBtnProps;
+ const nextButtonProps = props.nextButtonProps as TourBtnProps;
+
+ let headerNode: VueNode;
+ if (title) {
+ headerNode = (
+
+ );
+ }
+
+ let descriptionNode: VueNode;
+ if (description) {
+ descriptionNode = {description}
;
+ }
+
+ let coverNode: VueNode;
+ if (cover) {
+ coverNode = {cover}
;
+ }
+
+ let mergeIndicatorNode: VueNode;
+
+ if (slots.indicatorsRender) {
+ mergeIndicatorNode = slots.indicatorsRender({ current: current.value, total });
+ } else {
+ mergeIndicatorNode = [...Array.from({ length: total.value }).keys()].map(
+ (stepItem, index) => (
+
+ ),
+ );
+ }
+
+ const mainBtnType = stepType === 'primary' ? 'default' : 'primary';
+ const secondaryBtnProps: ButtonProps = {
+ type: 'default',
+ ghost: stepType === 'primary',
+ };
+
+ return (
+
+ {contextLocale => (
+
+ {arrow &&
}
+
+
+ {coverNode}
+ {headerNode}
+ {descriptionNode}
+
+
+
+ )}
+
+ );
+ };
+ },
+});
+
+export default panelRender;
diff --git a/components/tour/style/index.ts b/components/tour/style/index.ts
new file mode 100644
index 000000000..1e515a400
--- /dev/null
+++ b/components/tour/style/index.ts
@@ -0,0 +1,250 @@
+import { TinyColor } from '@ctrl/tinycolor';
+import type { FullToken, GenerateStyle } from '../../theme/internal';
+import { genComponentStyleHook, mergeToken } from '../../theme/internal';
+import { resetComponent } from '../../style';
+import getArrowStyle, { MAX_VERTICAL_CONTENT_RADIUS } from '../../style/placementArrow';
+
+export interface ComponentToken {}
+
+interface TourToken extends FullToken<'Tour'> {
+ tourZIndexPopup: number;
+ indicatorWidth: number;
+ indicatorHeight: number;
+ tourBorderRadius: number;
+ tourCloseSize: number;
+}
+
+// =============================== Base ===============================
+const genBaseStyle: GenerateStyle = token => {
+ const {
+ componentCls,
+ lineHeight,
+ padding,
+ paddingXS,
+ borderRadius,
+ borderRadiusXS,
+ colorPrimary,
+ colorText,
+ colorFill,
+ indicatorHeight,
+ indicatorWidth,
+ boxShadowTertiary,
+ tourZIndexPopup,
+ fontSize,
+ colorBgContainer,
+ fontWeightStrong,
+ marginXS,
+ colorTextLightSolid,
+ tourBorderRadius,
+ colorWhite,
+ colorBgTextHover,
+ tourCloseSize,
+ motionDurationSlow,
+ antCls,
+ } = token;
+
+ return [
+ {
+ [componentCls]: {
+ ...resetComponent(token),
+
+ color: colorText,
+ position: 'absolute',
+ zIndex: tourZIndexPopup,
+ display: 'block',
+ visibility: 'visible',
+ fontSize,
+ lineHeight,
+ width: 520,
+ '--antd-arrow-background-color': colorBgContainer,
+
+ '&-pure': {
+ maxWidth: '100%',
+ position: 'relative',
+ },
+
+ [`&${componentCls}-hidden`]: {
+ display: 'none',
+ },
+
+ // ============================= panel content ============================
+ [`${componentCls}-content`]: {
+ position: 'relative',
+ },
+ [`${componentCls}-inner`]: {
+ textAlign: 'start',
+ textDecoration: 'none',
+ borderRadius: tourBorderRadius,
+ boxShadow: boxShadowTertiary,
+ position: 'relative',
+ backgroundColor: colorBgContainer,
+ border: 'none',
+ backgroundClip: 'padding-box',
+
+ [`${componentCls}-close`]: {
+ position: 'absolute',
+ top: padding,
+ insetInlineEnd: padding,
+ color: token.colorIcon,
+ outline: 'none',
+ width: tourCloseSize,
+ height: tourCloseSize,
+ borderRadius: token.borderRadiusSM,
+ transition: `background-color ${token.motionDurationMid}, color ${token.motionDurationMid}`,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+
+ '&:hover': {
+ color: token.colorIconHover,
+ backgroundColor: token.wireframe ? 'transparent' : token.colorFillContent,
+ },
+ },
+
+ [`${componentCls}-cover`]: {
+ textAlign: 'center',
+ padding: `${padding + tourCloseSize + paddingXS}px ${padding}px 0`,
+ img: {
+ width: '100%',
+ },
+ },
+ [`${componentCls}-header`]: {
+ padding: `${padding}px ${padding}px ${paddingXS}px`,
+
+ [`${componentCls}-title`]: {
+ lineHeight,
+ fontSize,
+ fontWeight: fontWeightStrong,
+ },
+ },
+
+ [`${componentCls}-description`]: {
+ padding: `0 ${padding}px`,
+ lineHeight,
+ wordWrap: 'break-word',
+ },
+
+ [`${componentCls}-footer`]: {
+ padding: `${paddingXS}px ${padding}px ${padding}px`,
+ textAlign: 'end',
+ borderRadius: `0 0 ${borderRadiusXS}px ${borderRadiusXS}px`,
+ display: 'flex',
+ [`${componentCls}-indicators`]: {
+ display: 'inline-block',
+
+ [`${componentCls}-indicator`]: {
+ width: indicatorWidth,
+ height: indicatorHeight,
+ display: 'inline-block',
+ borderRadius: '50%',
+ background: colorFill,
+ '&:not(:last-child)': {
+ marginInlineEnd: indicatorHeight,
+ },
+ '&-active': {
+ background: colorPrimary,
+ },
+ },
+ },
+ [`${componentCls}-buttons`]: {
+ marginInlineStart: 'auto',
+ [`${antCls}-btn`]: {
+ marginInlineStart: marginXS,
+ },
+ },
+ },
+ },
+
+ // ============================= primary type ===========================
+ // `$` for panel, `&$` for pure panel
+ [`${componentCls}-primary, &${componentCls}-primary`]: {
+ '--antd-arrow-background-color': colorPrimary,
+
+ [`${componentCls}-inner`]: {
+ color: colorTextLightSolid,
+ textAlign: 'start',
+ textDecoration: 'none',
+ backgroundColor: colorPrimary,
+ borderRadius,
+ boxShadow: boxShadowTertiary,
+
+ [`${componentCls}-close`]: {
+ color: colorTextLightSolid,
+ },
+
+ [`${componentCls}-indicators`]: {
+ [`${componentCls}-indicator`]: {
+ background: new TinyColor(colorTextLightSolid).setAlpha(0.15).toRgbString(),
+ '&-active': {
+ background: colorTextLightSolid,
+ },
+ },
+ },
+
+ [`${componentCls}-prev-btn`]: {
+ color: colorTextLightSolid,
+ borderColor: new TinyColor(colorTextLightSolid).setAlpha(0.15).toRgbString(),
+ backgroundColor: colorPrimary,
+
+ '&:hover': {
+ backgroundColor: new TinyColor(colorTextLightSolid).setAlpha(0.15).toRgbString(),
+ borderColor: 'transparent',
+ },
+ },
+
+ [`${componentCls}-next-btn`]: {
+ color: colorPrimary,
+ borderColor: 'transparent',
+ background: colorWhite,
+
+ '&:hover': {
+ background: new TinyColor(colorBgTextHover).onBackground(colorWhite).toRgbString(),
+ },
+ },
+ },
+ },
+ },
+
+ // ============================= mask ===========================
+ [`${componentCls}-mask`]: {
+ [`${componentCls}-placeholder-animated`]: {
+ transition: `all ${motionDurationSlow}`,
+ },
+ },
+
+ // =========== Limit left and right placement radius ==============
+ [[
+ '&-placement-left',
+ '&-placement-leftTop',
+ '&-placement-leftBottom',
+ '&-placement-right',
+ '&-placement-rightTop',
+ '&-placement-rightBottom',
+ ].join(',')]: {
+ [`${componentCls}-inner`]: {
+ borderRadius: Math.min(tourBorderRadius, MAX_VERTICAL_CONTENT_RADIUS),
+ },
+ },
+ },
+
+ // ============================= Arrow ===========================
+ getArrowStyle(token, {
+ colorBg: 'var(--antd-arrow-background-color)',
+ contentRadius: tourBorderRadius,
+ limitVerticalRadius: true,
+ }),
+ ];
+};
+
+// ============================== Export ==============================
+export default genComponentStyleHook('Tour', token => {
+ const { borderRadiusLG, fontSize, lineHeight } = token;
+ const TourToken = mergeToken(token, {
+ tourZIndexPopup: token.zIndexPopupBase + 70,
+ indicatorWidth: 6,
+ indicatorHeight: 6,
+ tourBorderRadius: borderRadiusLG,
+ tourCloseSize: fontSize * lineHeight,
+ });
+ return [genBaseStyle(TourToken)];
+});
diff --git a/components/tour/useMergedType.ts b/components/tour/useMergedType.ts
new file mode 100644
index 000000000..efb0564d8
--- /dev/null
+++ b/components/tour/useMergedType.ts
@@ -0,0 +1,35 @@
+import useMergedState from '../_util/hooks/useMergedState';
+import type { TourProps } from './interface';
+import type { Ref } from 'vue';
+import { computed, watch } from 'vue';
+
+interface Props {
+ defaultType?: string;
+ steps?: TourProps['steps'];
+ current?: Ref;
+ defaultCurrent?: number;
+}
+
+/**
+ * returns the merged type of a step or the default type.
+ */
+const useMergedType = ({ defaultType, steps = [], current, defaultCurrent }: Props) => {
+ const [innerCurrent, updateInnerCurrent] = useMergedState(defaultCurrent, {
+ value: current,
+ });
+
+ watch(current, val => {
+ if (val === undefined) return;
+ updateInnerCurrent(val);
+ });
+
+ const innerType = computed(() => {
+ return typeof innerCurrent.value === 'number' ? steps[innerCurrent.value]?.type : defaultType;
+ });
+
+ const currentMergedType = computed(() => innerType.value ?? defaultType);
+
+ return { currentMergedType, updateInnerCurrent };
+};
+
+export default useMergedType;
diff --git a/components/vc-tour/Mask.tsx b/components/vc-tour/Mask.tsx
new file mode 100644
index 000000000..3791c931c
--- /dev/null
+++ b/components/vc-tour/Mask.tsx
@@ -0,0 +1,130 @@
+import type { CSSProperties } from 'vue';
+import { defineComponent } from 'vue';
+import classNames from '../_util/classNames';
+import type { PosInfo } from './hooks/useTarget';
+import useId from '../_util/hooks/useId';
+import Portal from '../_util/PortalWrapper';
+import { someType, objectType, booleanType } from '../_util/type';
+
+const COVER_PROPS = {
+ fill: 'transparent',
+ pointerEvents: 'auto',
+};
+
+export interface MaskProps {
+ prefixCls?: string;
+ pos: PosInfo; // 获取引导卡片指向的元素
+ rootClassName?: string;
+ showMask?: boolean;
+ style?: CSSProperties;
+ fill?: string;
+ open?: boolean;
+ animated?: boolean | { placeholder: boolean };
+ zIndex?: number;
+}
+const Mask = defineComponent({
+ name: 'Mask',
+ props: {
+ prefixCls: { type: String },
+ pos: objectType(), // 获取引导卡片指向的元素
+ rootClassName: { type: String },
+ showMask: booleanType(),
+ fill: { type: String, default: 'rgba(0,0,0,0.5)' },
+ open: booleanType(),
+ animated: someType([Boolean, Object]),
+ zIndex: { type: Number },
+ },
+ setup(props, { attrs }) {
+ return () => {
+ const { prefixCls, open, rootClassName, pos, showMask, fill, animated, zIndex } = props;
+
+ const id = useId();
+ const maskId = `${prefixCls}-mask-${id}`;
+ const mergedAnimated = typeof animated === 'object' ? animated?.placeholder : animated;
+
+ console.log(open);
+ return (
+
+ open && (
+
+ {showMask ? (
+
+ ) : null}
+
+ ),
+ }}
+ />
+ );
+ };
+ },
+});
+
+export default Mask;
diff --git a/components/vc-tour/Tour.tsx b/components/vc-tour/Tour.tsx
new file mode 100644
index 000000000..b7cd16234
--- /dev/null
+++ b/components/vc-tour/Tour.tsx
@@ -0,0 +1,243 @@
+import { ref, computed, watch, watchEffect, defineComponent, toRefs, shallowRef } from 'vue';
+import type { CSSProperties, ExtractPropTypes } from 'vue';
+import type { VueNode } from '../_util/type';
+import Trigger, { triggerProps } from '../vc-trigger';
+import classNames from '../_util/classNames';
+import useMergedState from '../_util/hooks/useMergedState';
+import useTarget from './hooks/useTarget';
+import type { Gap } from './hooks/useTarget';
+import TourStep from './TourStep';
+import type { TourStepInfo, TourStepProps } from './interface';
+import Mask from './Mask';
+import { getPlacements } from './placements';
+import type { PlacementType } from './placements';
+import { initDefaultProps } from '../_util/props-util';
+import useScrollLocker from './hooks/useScrollLocker';
+import canUseDom from '../_util/canUseDom';
+import {
+ someType,
+ stringType,
+ arrayType,
+ objectType,
+ functionType,
+ booleanType,
+} from '../_util/type';
+
+const CENTER_PLACEHOLDER: CSSProperties = {
+ left: '50%',
+ top: '50%',
+ width: 1,
+ height: 1,
+};
+
+export const tourProps = () => {
+ const { builtinPlacements, ...pickedTriggerProps } = triggerProps();
+ return {
+ ...pickedTriggerProps,
+ steps: arrayType(),
+ open: booleanType(),
+ defaultCurrent: { type: Number },
+ current: { type: Number },
+ onChange: functionType<(current: number) => void>(),
+ onClose: functionType<(current: number) => void>(),
+ onFinish: functionType<() => void>(),
+ mask: someType([Boolean, Object], true),
+ arrow: someType([Boolean, Object], true),
+ rootClassName: { type: String },
+ placement: stringType('bottom'),
+ prefixCls: { type: String, default: 'rc-tour' },
+ renderPanel: functionType<(props: TourStepProps, current: number) => VueNode>(),
+ gap: objectType(),
+ animated: someType([Boolean, Object]),
+ scrollIntoViewOptions: someType([Boolean, Object], true),
+ zIndex: { type: Number, default: 1001 },
+ };
+};
+
+export type TourProps = Partial>>;
+
+const Tour = defineComponent({
+ name: 'Tour',
+ props: initDefaultProps(tourProps(), {}),
+ setup(props) {
+ const { defaultCurrent, placement, mask, scrollIntoViewOptions, open, gap, arrow } =
+ toRefs(props);
+
+ const triggerRef = ref();
+
+ const [mergedCurrent, setMergedCurrent] = useMergedState(0, {
+ value: computed(() => props.current),
+ defaultValue: defaultCurrent.value,
+ });
+
+ const [mergedOpen, setMergedOpen] = useMergedState(undefined, {
+ value: computed(() => props.open),
+ postState: origin =>
+ mergedCurrent.value < 0 || mergedCurrent.value >= props.steps.length
+ ? false
+ : origin ?? true,
+ });
+
+ const openRef = shallowRef(mergedOpen.value);
+ watchEffect(() => {
+ if (mergedOpen.value && !openRef.value) {
+ setMergedCurrent(0);
+ }
+ openRef.value = mergedOpen.value;
+ });
+
+ const curStep = computed(() => (props.steps[mergedCurrent.value] || {}) as TourStepInfo);
+
+ const mergedPlacement = computed(() => curStep.value.placement ?? placement.value);
+ const mergedMask = computed(() => mergedOpen.value && (curStep.value.mask ?? mask.value));
+ const mergedScrollIntoViewOptions = computed(
+ () => curStep.value.scrollIntoViewOptions ?? scrollIntoViewOptions.value,
+ );
+ const [posInfo, targetElement] = useTarget(
+ computed(() => curStep.value.target),
+ open,
+ gap,
+ mergedScrollIntoViewOptions,
+ );
+
+ // ========================= arrow =========================
+ const mergedArrow = computed(() =>
+ targetElement.value
+ ? typeof curStep.value.arrow === 'undefined'
+ ? arrow.value
+ : curStep.value.arrow
+ : false,
+ );
+ const arrowPointAtCenter = computed(() =>
+ typeof mergedArrow.value === 'object' ? mergedArrow.value.pointAtCenter : false,
+ );
+
+ watch(arrowPointAtCenter, () => {
+ triggerRef.value?.forcePopupAlign();
+ });
+ watch(mergedCurrent, () => {
+ triggerRef.value?.forcePopupAlign();
+ });
+
+ // ========================= Change =========================
+ const onInternalChange = (nextCurrent: number) => {
+ setMergedCurrent(nextCurrent);
+ props.onChange?.(nextCurrent);
+ };
+
+ // ========================= lock scroll =========================
+ const lockScroll = computed(() => mergedOpen.value && canUseDom());
+
+ useScrollLocker(lockScroll);
+
+ return () => {
+ const {
+ prefixCls,
+ steps,
+ onClose,
+ onFinish,
+ rootClassName,
+ renderPanel,
+ animated,
+ zIndex,
+ ...restProps
+ } = props;
+
+ // ========================= Render =========================
+ // Skip if not init yet
+ if (targetElement.value === undefined) {
+ return null;
+ }
+
+ const handleClose = () => {
+ setMergedOpen(false);
+ onClose?.(mergedCurrent.value);
+ };
+
+ const mergedShowMask =
+ typeof mergedMask.value === 'boolean' ? mergedMask.value : !!mergedMask.value;
+ const mergedMaskStyle = typeof mergedMask.value === 'boolean' ? undefined : mergedMask.value;
+
+ // when targetElement is not exist, use body as triggerDOMNode
+ const getTriggerDOMNode = () => {
+ return targetElement.value || document.body;
+ };
+
+ const getPopupElement = () => (
+ {
+ onInternalChange(mergedCurrent.value - 1);
+ }}
+ onNext={() => {
+ onInternalChange(mergedCurrent.value + 1);
+ }}
+ onClose={handleClose}
+ current={mergedCurrent.value}
+ onFinish={() => {
+ handleClose();
+ onFinish?.();
+ }}
+ {...curStep.value}
+ />
+ );
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+ };
+ },
+});
+
+export default Tour;
diff --git a/components/vc-tour/TourStep/DefaultPanel.tsx b/components/vc-tour/TourStep/DefaultPanel.tsx
new file mode 100644
index 000000000..9e9b3fce9
--- /dev/null
+++ b/components/vc-tour/TourStep/DefaultPanel.tsx
@@ -0,0 +1,55 @@
+import { defineComponent } from 'vue';
+import classNames from '../../_util/classNames';
+import { tourStepProps } from '../interface';
+import type { TourStepProps } from '../interface';
+
+const DefaultPanel = defineComponent({
+ name: 'DefaultPanel',
+ props: tourStepProps(),
+ setup(props, { attrs }) {
+ return () => {
+ const { prefixCls, current, total, title, description, onClose, onPrev, onNext, onFinish } =
+ props as TourStepProps;
+ return (
+
+
+
+
+
{description}
+
+
+
+ );
+ };
+ },
+});
+
+export default DefaultPanel;
diff --git a/components/vc-tour/TourStep/index.tsx b/components/vc-tour/TourStep/index.tsx
new file mode 100644
index 000000000..96ddb7327
--- /dev/null
+++ b/components/vc-tour/TourStep/index.tsx
@@ -0,0 +1,25 @@
+import { defineComponent } from 'vue';
+import DefaultPanel from './DefaultPanel';
+import { tourStepProps } from '../interface';
+
+const TourStep = defineComponent({
+ name: 'TourStep',
+ props: tourStepProps(),
+ setup(props, { attrs }) {
+ return () => {
+ const { current, renderPanel } = props;
+
+ return (
+ <>
+ {typeof renderPanel === 'function' ? (
+ renderPanel({ ...attrs, ...props }, current)
+ ) : (
+
+ )}
+ >
+ );
+ };
+ },
+});
+
+export default TourStep;
diff --git a/components/vc-tour/hooks/useScrollLocker.tsx b/components/vc-tour/hooks/useScrollLocker.tsx
new file mode 100644
index 000000000..5d5f1cfa1
--- /dev/null
+++ b/components/vc-tour/hooks/useScrollLocker.tsx
@@ -0,0 +1,44 @@
+import type { Ref } from 'vue';
+import { computed, watchEffect } from 'vue';
+import { updateCSS, removeCSS } from '../../vc-util/Dom/dynamicCSS';
+import getScrollBarSize from '../../_util/getScrollBarSize';
+
+const UNIQUE_ID = `vc-util-locker-${Date.now()}`;
+
+let uuid = 0;
+
+/**../vc-util/Dom/dynam
+ * Test usage export. Do not use in your production
+ */
+export function isBodyOverflowing() {
+ return (
+ document.body.scrollHeight > (window.innerHeight || document.documentElement.clientHeight) &&
+ window.innerWidth > document.body.offsetWidth
+ );
+}
+
+export default function useScrollLocker(lock?: Ref) {
+ const mergedLock = computed(() => !!lock && !!lock.value);
+ const id = computed(() => {
+ uuid += 1;
+ return `${UNIQUE_ID}_${uuid}`;
+ });
+
+ watchEffect(() => {
+ if (mergedLock.value) {
+ const scrollbarSize = getScrollBarSize();
+ const isOverflow = isBodyOverflowing();
+
+ updateCSS(
+ `
+html body {
+ overflow-y: hidden;
+ ${isOverflow ? `width: calc(100% - ${scrollbarSize}px);` : ''}
+}`,
+ id.value,
+ );
+ } else {
+ removeCSS(id.value);
+ }
+ });
+}
diff --git a/components/vc-tour/hooks/useTarget.ts b/components/vc-tour/hooks/useTarget.ts
new file mode 100644
index 000000000..4107a37ec
--- /dev/null
+++ b/components/vc-tour/hooks/useTarget.ts
@@ -0,0 +1,100 @@
+import { computed, watchEffect, watch } from 'vue';
+import type { Ref } from 'vue';
+import { isInViewPort } from '../util';
+import type { TourStepInfo } from '..';
+
+import useState from '../../_util/hooks/useState';
+
+export interface Gap {
+ offset?: number;
+ radius?: number;
+}
+
+export interface PosInfo {
+ left: number;
+ top: number;
+ height: number;
+ width: number;
+ radius: number;
+}
+
+export default function useTarget(
+ target: Ref,
+ open: Ref,
+ gap?: Ref,
+ scrollIntoViewOptions?: Ref,
+): [Ref, Ref] {
+ // ========================= Target =========================
+ // We trade `undefined` as not get target by function yet.
+ // `null` as empty target.
+ const [targetElement, setTargetElement] = useState(undefined);
+
+ watchEffect(() => {
+ const nextElement = typeof target.value === 'function' ? (target.value as any)() : target.value;
+
+ setTargetElement(nextElement || null);
+ });
+
+ // ========================= Align ==========================
+ const [posInfo, setPosInfo] = useState(null);
+
+ const updatePos = () => {
+ if (targetElement.value) {
+ // Exist target element. We should scroll and get target position
+ if (!isInViewPort(targetElement.value) && open.value) {
+ targetElement.value.scrollIntoView(scrollIntoViewOptions.value);
+ }
+
+ const { left, top, width, height } = targetElement.value.getBoundingClientRect();
+ const nextPosInfo: PosInfo = { left, top, width, height, radius: 0 };
+
+ setPosInfo(nextPosInfo);
+ } else {
+ // Not exist target which means we just show in center
+ setPosInfo(null);
+ }
+ };
+
+ watchEffect(() => {
+ updatePos();
+ // update when window resize
+ window.addEventListener('resize', updatePos);
+ return () => {
+ window.removeEventListener('resize', updatePos);
+ };
+ });
+
+ watch(
+ open,
+ val => {
+ updatePos();
+ // update when window resize
+ if (val) {
+ window.addEventListener('resize', updatePos);
+ } else {
+ window.removeEventListener('resize', updatePos);
+ }
+ },
+ { immediate: true },
+ );
+
+ // ======================== PosInfo =========================
+ const mergedPosInfo = computed(() => {
+ if (!posInfo.value) {
+ return posInfo.value;
+ }
+
+ const gapOffset = gap.value?.offset || 6;
+ const gapRadius = gap.value?.radius || 2;
+
+ return {
+ left: posInfo.value.left - gapOffset,
+ top: posInfo.value.top - gapOffset,
+ width: posInfo.value.width + gapOffset * 2,
+ height: posInfo.value.height + gapOffset * 2,
+ radius: gapRadius,
+ };
+ });
+
+ return [mergedPosInfo, targetElement];
+}
diff --git a/components/vc-tour/index.tsx b/components/vc-tour/index.tsx
new file mode 100644
index 000000000..038860e2a
--- /dev/null
+++ b/components/vc-tour/index.tsx
@@ -0,0 +1,6 @@
+import Tour from './Tour';
+export type { TourProps } from './Tour';
+export { tourProps } from './Tour';
+export type { TourStepInfo, TourStepProps } from './interface';
+export { tourStepInfo, tourStepProps } from './interface';
+export default Tour;
diff --git a/components/vc-tour/interface.ts b/components/vc-tour/interface.ts
new file mode 100644
index 000000000..e2538ffc1
--- /dev/null
+++ b/components/vc-tour/interface.ts
@@ -0,0 +1,36 @@
+import type { ExtractPropTypes, CSSProperties } from 'vue';
+import type { PlacementType } from './placements';
+import type { VueNode } from '../_util/type';
+import { someType, stringType, objectType, functionType } from '../_util/type';
+
+export const tourStepInfo = () => ({
+ arrow: someType([Boolean, Object]),
+ target: someType HTMLElement) | null | (() => null)>([
+ String,
+ Function,
+ Object,
+ ]),
+ title: someType([String, Object]),
+ description: someType([String, Object]),
+ placement: stringType(),
+ mask: someType([Object, Boolean], true),
+ className: { type: String },
+ style: objectType(),
+ scrollIntoViewOptions: someType([Boolean, Object]),
+});
+
+export type TourStepInfo = Partial>>;
+
+export const tourStepProps = () => ({
+ ...tourStepInfo(),
+ prefixCls: { type: String },
+ total: { type: Number },
+ current: { type: Number },
+ onClose: functionType<(e: MouseEvent) => void>(),
+ onFinish: functionType<(e: MouseEvent) => void>(),
+ renderPanel: functionType<(step: any, current: number) => VueNode>(),
+ onPrev: functionType<(e: MouseEvent) => void>(),
+ onNext: functionType<(e: MouseEvent) => void>(),
+});
+
+export type TourStepProps = Partial>>;
diff --git a/components/vc-tour/placements.tsx b/components/vc-tour/placements.tsx
new file mode 100644
index 000000000..cb51093e0
--- /dev/null
+++ b/components/vc-tour/placements.tsx
@@ -0,0 +1,135 @@
+export type PlacementType =
+ | 'left'
+ | 'leftTop'
+ | 'leftBottom'
+ | 'right'
+ | 'rightTop'
+ | 'rightBottom'
+ | 'top'
+ | 'topLeft'
+ | 'topRight'
+ | 'bottom'
+ | 'bottomLeft'
+ | 'bottomRight'
+ | 'center';
+
+const targetOffset = [0, 0];
+
+export type AlignPointTopBottom = 't' | 'b' | 'c';
+export type AlignPointLeftRight = 'l' | 'r' | 'c';
+
+/** Two char of 't' 'b' 'c' 'l' 'r'. Example: 'lt' */
+export type AlignPoint = `${AlignPointTopBottom}${AlignPointLeftRight}`;
+
+export interface AlignType {
+ /**
+ * move point of source node to align with point of target node.
+ * Such as ['tr','cc'], align top right point of source node with center point of target node.
+ * Point can be 't'(top), 'b'(bottom), 'c'(center), 'l'(left), 'r'(right) */
+ points?: (string | AlignPoint)[];
+ /**
+ * offset source node by offset[0] in x and offset[1] in y.
+ * If offset contains percentage string value, it is relative to sourceNode region.
+ */
+ offset?: number[];
+ /**
+ * offset target node by offset[0] in x and offset[1] in y.
+ * If targetOffset contains percentage string value, it is relative to targetNode region.
+ */
+ targetOffset?: number[];
+ /**
+ * If adjustX field is true, will adjust source node in x direction if source node is invisible.
+ * If adjustY field is true, will adjust source node in y direction if source node is invisible.
+ */
+ overflow?: {
+ adjustX?: boolean | number;
+ adjustY?: boolean | number;
+ shiftX?: boolean | number;
+ shiftY?: boolean | number;
+ };
+ /** Auto adjust arrow position */
+ autoArrow?: boolean;
+ /**
+ * Config visible region check of html node. Default `visible`:
+ * - `visible`: The visible region of user browser window. Use `clientHeight` for check.
+ * - `scroll`: The whole region of the html scroll area. Use `scrollHeight` for check.
+ */
+ htmlRegion?: 'visible' | 'scroll';
+ /**
+ * Whether use css right instead of left to position
+ */
+ useCssRight?: boolean;
+ /**
+ * Whether use css bottom instead of top to position
+ */
+ useCssBottom?: boolean;
+ /**
+ * Whether use css transform instead of left/top/right/bottom to position if browser supports.
+ * Defaults to false.
+ */
+ useCssTransform?: boolean;
+ ignoreShake?: boolean;
+}
+
+export type BuildInPlacements = Record;
+
+const basePlacements: BuildInPlacements = {
+ left: {
+ points: ['cr', 'cl'],
+ offset: [-8, 0],
+ },
+ right: {
+ points: ['cl', 'cr'],
+ offset: [8, 0],
+ },
+ top: {
+ points: ['bc', 'tc'],
+ offset: [0, -8],
+ },
+ bottom: {
+ points: ['tc', 'bc'],
+ offset: [0, 8],
+ },
+ topLeft: {
+ points: ['bl', 'tl'],
+ offset: [0, -8],
+ },
+ leftTop: {
+ points: ['tr', 'tl'],
+ offset: [-8, 0],
+ },
+ topRight: {
+ points: ['br', 'tr'],
+ offset: [0, -8],
+ },
+ rightTop: {
+ points: ['tl', 'tr'],
+ offset: [8, 0],
+ },
+ bottomRight: {
+ points: ['tr', 'br'],
+ offset: [0, 8],
+ },
+ rightBottom: {
+ points: ['bl', 'br'],
+ offset: [8, 0],
+ },
+ bottomLeft: {
+ points: ['tl', 'bl'],
+ offset: [0, 8],
+ },
+ leftBottom: {
+ points: ['br', 'bl'],
+ offset: [-8, 0],
+ },
+};
+
+export function getPlacements(arrowPointAtCenter = false) {
+ const placements: BuildInPlacements = {};
+ Object.keys(basePlacements).forEach(key => {
+ placements[key] = { ...basePlacements[key], autoArrow: arrowPointAtCenter, targetOffset };
+ });
+ return placements;
+}
+
+export const placements = getPlacements();
diff --git a/components/vc-tour/util.ts b/components/vc-tour/util.ts
new file mode 100644
index 000000000..a02d82f6a
--- /dev/null
+++ b/components/vc-tour/util.ts
@@ -0,0 +1,7 @@
+export 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;
+}
diff --git a/components/vc-trigger/Trigger.tsx b/components/vc-trigger/Trigger.tsx
index 418a20b5d..79ec179d4 100644
--- a/components/vc-trigger/Trigger.tsx
+++ b/components/vc-trigger/Trigger.tsx
@@ -1,6 +1,6 @@
-import type { CSSProperties, HTMLAttributes, PropType } from 'vue';
+import type { HTMLAttributes } from 'vue';
import { computed, defineComponent, inject, provide, shallowRef } from 'vue';
-import PropTypes from '../_util/vue-types';
+import { triggerProps, noop } from './interface';
import contains from '../vc-util/Dom/contains';
import raf from '../_util/raf';
import {
@@ -21,17 +21,6 @@ import { cloneElement } from '../_util/vnode';
import supportsPassive from '../_util/supportsPassive';
import { useInjectTrigger, useProvidePortal } from './context';
-function noop() {}
-function returnEmptyString() {
- return '';
-}
-
-function returnDocument(element) {
- if (element) {
- return element.ownerDocument;
- }
- return window.document;
-}
const ALL_HANDLERS = [
'onClick',
'onMousedown',
@@ -47,46 +36,7 @@ export default defineComponent({
name: 'Trigger',
mixins: [BaseMixin],
inheritAttrs: false,
- props: {
- action: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]).def([]),
- showAction: PropTypes.any.def([]),
- hideAction: PropTypes.any.def([]),
- getPopupClassNameFromAlign: PropTypes.any.def(returnEmptyString),
- onPopupVisibleChange: Function as PropType<(open: boolean) => void>,
- afterPopupVisibleChange: PropTypes.func.def(noop),
- popup: PropTypes.any,
- popupStyle: { type: Object as PropType, default: undefined as CSSProperties },
- prefixCls: PropTypes.string.def('rc-trigger-popup'),
- popupClassName: PropTypes.string.def(''),
- popupPlacement: String,
- builtinPlacements: PropTypes.object,
- popupTransitionName: String,
- popupAnimation: PropTypes.any,
- mouseEnterDelay: PropTypes.number.def(0),
- mouseLeaveDelay: PropTypes.number.def(0.1),
- zIndex: Number,
- focusDelay: PropTypes.number.def(0),
- blurDelay: PropTypes.number.def(0.15),
- getPopupContainer: Function,
- getDocument: PropTypes.func.def(returnDocument),
- forceRender: { type: Boolean, default: undefined },
- destroyPopupOnHide: { type: Boolean, default: false },
- mask: { type: Boolean, default: false },
- maskClosable: { type: Boolean, default: true },
- // onPopupAlign: PropTypes.func.def(noop),
- popupAlign: PropTypes.object.def(() => ({})),
- popupVisible: { type: Boolean, default: undefined },
- defaultPopupVisible: { type: Boolean, default: false },
- maskTransitionName: String,
- maskAnimation: String,
- stretch: String,
- alignPoint: { type: Boolean, default: undefined }, // Maybe we can support user pass position in the future
- autoDestroy: { type: Boolean, default: false },
- mobile: Object,
- getTriggerDOMNode: Function as PropType<(d?: HTMLElement) => HTMLElement>,
- // portal context will change
- tryPopPortal: Boolean, // no need reactive
- },
+ props: triggerProps(),
setup(props) {
const align = computed(() => {
const { popupPlacement, popupAlign, builtinPlacements } = props;
diff --git a/components/vc-trigger/index.ts b/components/vc-trigger/index.ts
index dcf37bdc3..2c8bd12eb 100644
--- a/components/vc-trigger/index.ts
+++ b/components/vc-trigger/index.ts
@@ -1,3 +1,8 @@
// based on rc-trigger 5.2.10
import Trigger from './Trigger';
+import { triggerProps } from './interface';
+import type { TriggerProps } from './interface';
+
+export { triggerProps };
+export type { TriggerProps };
export default Trigger;
diff --git a/components/vc-trigger/interface.ts b/components/vc-trigger/interface.ts
index 7ef79c111..9cf6b3ad6 100644
--- a/components/vc-trigger/interface.ts
+++ b/components/vc-trigger/interface.ts
@@ -1,5 +1,6 @@
-import type { CSSProperties, TransitionProps } from 'vue';
+import type { CSSProperties, ExtractPropTypes, TransitionProps, PropType } from 'vue';
import type { VueNode } from '../_util/type';
+import PropTypes from '../_util/vue-types';
/** Two char of 't' 'b' 'c' 'l' 'r'. Example: 'lt' */
export type AlignPoint = string;
@@ -70,3 +71,59 @@ export interface MobileConfig {
popupStyle?: CSSProperties;
popupRender?: (originNode: VueNode) => VueNode;
}
+
+function returnEmptyString() {
+ return '';
+}
+
+function returnDocument(element) {
+ if (element) {
+ return element.ownerDocument;
+ }
+ return window.document;
+}
+
+export function noop() {}
+
+export const triggerProps = () => ({
+ action: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]).def([]),
+ showAction: PropTypes.any.def([]),
+ hideAction: PropTypes.any.def([]),
+ getPopupClassNameFromAlign: PropTypes.any.def(returnEmptyString),
+ onPopupVisibleChange: Function as PropType<(open: boolean) => void>,
+ afterPopupVisibleChange: PropTypes.func.def(noop),
+ popup: PropTypes.any,
+ popupStyle: { type: Object as PropType, default: undefined as CSSProperties },
+ prefixCls: PropTypes.string.def('rc-trigger-popup'),
+ popupClassName: PropTypes.string.def(''),
+ popupPlacement: String,
+ builtinPlacements: PropTypes.object,
+ popupTransitionName: String,
+ popupAnimation: PropTypes.any,
+ mouseEnterDelay: PropTypes.number.def(0),
+ mouseLeaveDelay: PropTypes.number.def(0.1),
+ zIndex: Number,
+ focusDelay: PropTypes.number.def(0),
+ blurDelay: PropTypes.number.def(0.15),
+ getPopupContainer: Function,
+ getDocument: PropTypes.func.def(returnDocument),
+ forceRender: { type: Boolean, default: undefined },
+ destroyPopupOnHide: { type: Boolean, default: false },
+ mask: { type: Boolean, default: false },
+ maskClosable: { type: Boolean, default: true },
+ // onPopupAlign: PropTypes.func.def(noop),
+ popupAlign: PropTypes.object.def(() => ({})),
+ popupVisible: { type: Boolean, default: undefined },
+ defaultPopupVisible: { type: Boolean, default: false },
+ maskTransitionName: String,
+ maskAnimation: String,
+ stretch: String,
+ alignPoint: { type: Boolean, default: undefined }, // Maybe we can support user pass position in the future
+ autoDestroy: { type: Boolean, default: false },
+ mobile: Object,
+ getTriggerDOMNode: Function as PropType<(d?: HTMLElement) => HTMLElement>,
+ // portal context will change
+ tryPopPortal: Boolean, // no need reactive
+});
+
+export type TriggerProps = Partial>>;