diff --git a/.changeset/add-tour-component.md b/.changeset/add-tour-component.md new file mode 100644 index 000000000..e66647bd4 --- /dev/null +++ b/.changeset/add-tour-component.md @@ -0,0 +1,5 @@ +--- +"@tiny-design/react": minor +--- + +Add Tour component for step-by-step product guides with spotlight mask, Popper.js positioning, keyboard navigation, and customizable step indicators diff --git a/apps/docs/src/routers.tsx b/apps/docs/src/routers.tsx index 315d22b7b..d27b2f981 100755 --- a/apps/docs/src/routers.tsx +++ b/apps/docs/src/routers.tsx @@ -103,6 +103,7 @@ const c = { textLoop: ll(() => import('../../../packages/react/src/text-loop/index.md'), () => import('../../../packages/react/src/text-loop/index.zh_CN.md')), timeline: ll(() => import('../../../packages/react/src/timeline/index.md'), () => import('../../../packages/react/src/timeline/index.zh_CN.md')), tooltip: ll(() => import('../../../packages/react/src/tooltip/index.md'), () => import('../../../packages/react/src/tooltip/index.zh_CN.md')), + tour: ll(() => import('../../../packages/react/src/tour/index.md'), () => import('../../../packages/react/src/tour/index.zh_CN.md')), tree: ll(() => import('../../../packages/react/src/tree/index.md'), () => import('../../../packages/react/src/tree/index.zh_CN.md')), form: ll(() => import('../../../packages/react/src/form/index.md'), () => import('../../../packages/react/src/form/index.zh_CN.md')), cascader: ll(() => import('../../../packages/react/src/cascader/index.md'), () => import('../../../packages/react/src/cascader/index.zh_CN.md')), @@ -300,6 +301,7 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => { { title: 'Modal', route: 'modal', component: pick(c.modal, z) }, { title: 'Notification', route: 'notification', component: pick(c.notification, z) }, { title: 'PopConfirm', route: 'pop-confirm', component: pick(c.popConfirm, z) }, + { title: 'Tour', route: 'tour', component: pick(c.tour, z), tag: New }, { title: 'Result', route: 'result', component: pick(c.result, z) }, { title: 'ScrollIndicator', route: 'scroll-indicator', component: pick(c.scrollIndicator, z) }, { title: 'Skeleton', route: 'skeleton', component: pick(c.skeleton, z) }, diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 13d68dcf0..7b64e7863 100755 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -77,6 +77,7 @@ export { default as Textarea } from './textarea'; export { default as Timeline } from './timeline'; export { default as TimePicker } from './time-picker'; export { default as Tooltip } from './tooltip'; +export { default as Tour } from './tour'; export { default as Transfer } from './transfer'; export { default as Transition } from './transition'; export { default as Tree } from './tree'; @@ -92,3 +93,4 @@ export type { Locale } from './locale'; export { useLocale } from './_utils/use-locale'; export { useTheme } from './_utils/use-theme'; export type { ThemeMode } from './_utils/use-theme'; +export type { TourProps, TourStepProps } from './tour'; diff --git a/packages/react/src/locale/en_US.ts b/packages/react/src/locale/en_US.ts index 9f48ccaa3..c49a2e5d7 100644 --- a/packages/react/src/locale/en_US.ts +++ b/packages/react/src/locale/en_US.ts @@ -39,6 +39,11 @@ const en_US: Locale = { rgb: 'RGB', hsb: 'HSB', }, + Tour: { + prevText: 'Previous', + nextText: 'Next', + finishText: 'Finish', + }, }; export default en_US; diff --git a/packages/react/src/locale/types.ts b/packages/react/src/locale/types.ts index 7056598e1..215832f48 100644 --- a/packages/react/src/locale/types.ts +++ b/packages/react/src/locale/types.ts @@ -37,4 +37,9 @@ export type Locale = { rgb: string; hsb: string; }; + Tour: { + prevText: string; + nextText: string; + finishText: string; + }; }; diff --git a/packages/react/src/locale/zh_CN.ts b/packages/react/src/locale/zh_CN.ts index e63c21b88..9110bc395 100644 --- a/packages/react/src/locale/zh_CN.ts +++ b/packages/react/src/locale/zh_CN.ts @@ -39,6 +39,11 @@ const zh_CN: Locale = { rgb: 'RGB', hsb: 'HSB', }, + Tour: { + prevText: '上一步', + nextText: '下一步', + finishText: '完成', + }, }; export default zh_CN; diff --git a/packages/react/src/style/_component.scss b/packages/react/src/style/_component.scss index 4a11ebaf4..af028a463 100644 --- a/packages/react/src/style/_component.scss +++ b/packages/react/src/style/_component.scss @@ -74,6 +74,7 @@ @use "../timeline/style/index" as *; @use "../time-picker/style/index" as *; @use "../tooltip/style/index" as *; +@use "../tour/style/index" as *; @use "../transfer/style/index" as *; @use "../transition/style/index" as *; @use "../tree/style/index" as *; diff --git a/packages/react/src/tour/__tests__/tour.test.tsx b/packages/react/src/tour/__tests__/tour.test.tsx new file mode 100644 index 000000000..52f278be2 --- /dev/null +++ b/packages/react/src/tour/__tests__/tour.test.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import Tour from '../index'; +import { TourStepProps } from '../types'; + +const steps: TourStepProps[] = [ + { title: 'Step 1', description: 'Description 1' }, + { title: 'Step 2', description: 'Description 2' }, + { title: 'Step 3', description: 'Description 3' }, +]; + +describe('', () => { + it('should not render when open is false', () => { + const { baseElement } = render(); + expect(baseElement.querySelector('.ty-tour')).not.toBeInTheDocument(); + }); + + it('should render when open is true', () => { + const { getByText } = render(); + expect(getByText('Step 1')).toBeInTheDocument(); + expect(getByText('Description 1')).toBeInTheDocument(); + }); + + it('should render step title and description', () => { + const { getByText } = render(); + expect(getByText('Step 1')).toBeInTheDocument(); + expect(getByText('Description 1')).toBeInTheDocument(); + }); + + it('should navigate to next step', () => { + const onChange = jest.fn(); + const { getByText } = render(); + fireEvent.click(getByText('Next')); + expect(onChange).toHaveBeenCalledWith(1); + }); + + it('should navigate to previous step', () => { + const onChange = jest.fn(); + const { getByText } = render(); + fireEvent.click(getByText('Previous')); + expect(onChange).toHaveBeenCalledWith(0); + }); + + it('should call onClose when close button is clicked', () => { + const onClose = jest.fn(); + const { baseElement } = render(); + const closeBtn = baseElement.querySelector('.ty-tour__close-btn') as HTMLElement; + fireEvent.click(closeBtn); + expect(onClose).toHaveBeenCalled(); + }); + + it('should call onFinish on last step next click', () => { + const onFinish = jest.fn(); + const onClose = jest.fn(); + const { getByText } = render( + + ); + fireEvent.click(getByText('Finish')); + expect(onFinish).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it('should render indicators', () => { + const { baseElement } = render(); + const indicators = baseElement.querySelectorAll('.ty-tour__indicator'); + expect(indicators).toHaveLength(3); + }); + + it('should show active indicator for current step', () => { + const { baseElement } = render(); + const indicators = baseElement.querySelectorAll('.ty-tour__indicator'); + expect(indicators[1]).toHaveClass('ty-tour__indicator_active'); + }); + + it('should render mask by default', () => { + const { baseElement } = render(); + expect(baseElement.querySelector('.ty-tour__mask')).toBeInTheDocument(); + }); + + it('should not render mask when mask is false', () => { + const { baseElement } = render(); + expect(baseElement.querySelector('.ty-tour__mask')).not.toBeInTheDocument(); + }); + + it('should render primary type', () => { + const { baseElement } = render(); + expect(baseElement.querySelector('.ty-tour__panel_primary')).toBeInTheDocument(); + }); + + it('should handle keyboard Escape', () => { + const onClose = jest.fn(); + render(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('should center panel when no target', () => { + const { baseElement } = render(); + expect( + baseElement.querySelector('.ty-tour__panel-wrapper_centered') + ).toBeInTheDocument(); + }); + + it('should not render previous button on first step', () => { + const { queryByText } = render(); + expect(queryByText('Previous')).not.toBeInTheDocument(); + }); + + it('should show Finish text on last step', () => { + const { getByText } = render(); + expect(getByText('Finish')).toBeInTheDocument(); + }); + + it('should support custom indicatorsRender', () => { + const { getByText } = render( + {`${current + 1}/${total}`}} + /> + ); + expect(getByText('1/3')).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/tour/demo/Basic.tsx b/packages/react/src/tour/demo/Basic.tsx new file mode 100644 index 000000000..bc469e442 --- /dev/null +++ b/packages/react/src/tour/demo/Basic.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Tour, Button } from '@tiny-design/react'; +import type { TourStepProps } from '../../tour/types'; + +export default function BasicDemo() { + const [open, setOpen] = React.useState(false); + const ref1 = React.useRef(null); + const ref2 = React.useRef(null); + const ref3 = React.useRef(null); + + const steps: TourStepProps[] = [ + { + title: 'Upload File', + description: 'Put your files here.', + target: () => ref1.current, + }, + { + title: 'Save', + description: 'Save your changes.', + target: () => ref2.current, + }, + { + title: 'Other Actions', + description: 'Click to see other actions.', + target: () => ref3.current, + }, + ]; + + return ( + <> + + Upload + Save + setOpen(true)}> + Start Tour + + + setOpen(false)} /> + > + ); +} diff --git a/packages/react/src/tour/demo/Cover.tsx b/packages/react/src/tour/demo/Cover.tsx new file mode 100644 index 000000000..bb4d6496e --- /dev/null +++ b/packages/react/src/tour/demo/Cover.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Tour, Button } from '@tiny-design/react'; +import type { TourStepProps } from '../../tour/types'; + +export default function CoverDemo() { + const [open, setOpen] = React.useState(false); + const ref1 = React.useRef(null); + const ref2 = React.useRef(null); + + const steps: TourStepProps[] = [ + { + title: 'Create a project', + description: 'Start by creating a new project from the dashboard.', + cover: ( + + + + Step 1 + + ), + target: () => ref1.current, + }, + { + title: 'Upload files', + description: 'Upload files to your project to get started.', + cover: ( + + + + Step 2 + + ), + target: () => ref2.current, + }, + ]; + + return ( + <> + + Create + setOpen(true)}> + Start Tour + + + setOpen(false)} /> + > + ); +} diff --git a/packages/react/src/tour/demo/CustomButton.tsx b/packages/react/src/tour/demo/CustomButton.tsx new file mode 100644 index 000000000..e90bce3d1 --- /dev/null +++ b/packages/react/src/tour/demo/CustomButton.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Tour, Button } from '@tiny-design/react'; +import type { TourStepProps } from '../../tour/types'; + +export default function CustomButtonDemo() { + const [open, setOpen] = React.useState(false); + const ref1 = React.useRef(null); + const ref2 = React.useRef(null); + const ref3 = React.useRef(null); + + const steps: TourStepProps[] = [ + { + title: 'Upload File', + description: 'Put your files here.', + target: () => ref1.current, + nextButtonProps: { children: 'Go Next' }, + }, + { + title: 'Save', + description: 'Save your changes.', + target: () => ref2.current, + prevButtonProps: { children: 'Go Back' }, + nextButtonProps: { children: 'Continue' }, + }, + { + title: 'Other Actions', + description: 'Click to see other actions.', + target: () => ref3.current, + prevButtonProps: { children: 'Go Back' }, + nextButtonProps: { children: 'Done!' }, + }, + ]; + + return ( + <> + + Upload + Save + setOpen(true)}> + Start Tour + + + setOpen(false)} /> + > + ); +} diff --git a/packages/react/src/tour/demo/CustomIndicator.tsx b/packages/react/src/tour/demo/CustomIndicator.tsx new file mode 100644 index 000000000..09feca4c0 --- /dev/null +++ b/packages/react/src/tour/demo/CustomIndicator.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Tour, Button } from '@tiny-design/react'; +import type { TourStepProps } from '../../tour/types'; + +export default function CustomIndicatorDemo() { + const [open, setOpen] = React.useState(false); + const ref1 = React.useRef(null); + const ref2 = React.useRef(null); + const ref3 = React.useRef(null); + + const steps: TourStepProps[] = [ + { + title: 'Upload File', + description: 'Put your files here.', + target: () => ref1.current, + }, + { + title: 'Save', + description: 'Save your changes.', + target: () => ref2.current, + }, + { + title: 'Other Actions', + description: 'Click to see other actions.', + target: () => ref3.current, + }, + ]; + + return ( + <> + + Upload + Save + setOpen(true)}> + Start Tour + + + setOpen(false)} + indicatorsRender={(current, total) => ( + {`${current + 1} / ${total}`} + )} + /> + > + ); +} diff --git a/packages/react/src/tour/demo/Gap.tsx b/packages/react/src/tour/demo/Gap.tsx new file mode 100644 index 000000000..d2322e1d1 --- /dev/null +++ b/packages/react/src/tour/demo/Gap.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Tour, Button, Slider } from '@tiny-design/react'; +import type { TourStepProps } from '../../tour/types'; + +export default function GapDemo() { + const [open, setOpen] = React.useState(false); + const [offset, setOffset] = React.useState(6); + const [radius, setRadius] = React.useState(2); + const ref = React.useRef(null); + + const steps: TourStepProps[] = [ + { + title: 'Custom Gap', + description: 'The spotlight has custom padding and rounded corners around the target.', + target: () => ref.current, + }, + ]; + + return ( + + + Offset: {offset}px + setOffset(v as number)} /> + + + Radius: {radius}px + setRadius(v as number)} /> + + setOpen(true)}> + Start Tour + + setOpen(false)} + /> + + ); +} diff --git a/packages/react/src/tour/demo/NoMask.tsx b/packages/react/src/tour/demo/NoMask.tsx new file mode 100644 index 000000000..87889b660 --- /dev/null +++ b/packages/react/src/tour/demo/NoMask.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Tour, Button } from '@tiny-design/react'; +import type { TourStepProps } from '../../tour/types'; + +export default function NoMaskDemo() { + const [open, setOpen] = React.useState(false); + const ref1 = React.useRef(null); + const ref2 = React.useRef(null); + + const steps: TourStepProps[] = [ + { + title: 'Upload File', + description: 'Put your files here.', + target: () => ref1.current, + }, + { + title: 'Save', + description: 'Save your changes.', + target: () => ref2.current, + }, + ]; + + return ( + <> + + Upload + setOpen(true)}> + Start Tour + + + setOpen(false)} /> + > + ); +} diff --git a/packages/react/src/tour/demo/NoTarget.tsx b/packages/react/src/tour/demo/NoTarget.tsx new file mode 100644 index 000000000..6008628ac --- /dev/null +++ b/packages/react/src/tour/demo/NoTarget.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Tour, Button } from '@tiny-design/react'; +import type { TourStepProps } from '../../tour/types'; + +export default function NoTargetDemo() { + const [open, setOpen] = React.useState(false); + const ref = React.useRef(null); + + const steps: TourStepProps[] = [ + { + title: 'Welcome', + description: 'Welcome to the app! This step has no target, so it appears centered.', + }, + { + title: 'Click Here', + description: 'This step highlights a specific element.', + target: () => ref.current, + }, + { + title: 'All Done', + description: 'You have completed the tour. This step is centered again.', + }, + ]; + + return ( + <> + + setOpen(true)}> + Start Tour + + + setOpen(false)} /> + > + ); +} diff --git a/packages/react/src/tour/demo/Placement.tsx b/packages/react/src/tour/demo/Placement.tsx new file mode 100644 index 000000000..e00ddb0ff --- /dev/null +++ b/packages/react/src/tour/demo/Placement.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Tour, Button } from '@tiny-design/react'; +import type { TourStepProps } from '../../tour/types'; + +export default function PlacementDemo() { + const [open, setOpen] = React.useState(false); + const ref = React.useRef(null); + + const steps: TourStepProps[] = [ + { + title: 'Top Placement', + description: 'This tooltip is placed on top.', + target: () => ref.current, + placement: 'top', + }, + { + title: 'Right Placement', + description: 'This tooltip is placed on the right.', + target: () => ref.current, + placement: 'right', + }, + { + title: 'Bottom Placement', + description: 'This tooltip is placed at the bottom.', + target: () => ref.current, + placement: 'bottom', + }, + { + title: 'Left Placement', + description: 'This tooltip is placed on the left.', + target: () => ref.current, + placement: 'left', + }, + ]; + + return ( + <> + + setOpen(true)}> + Start Tour + + + setOpen(false)} /> + > + ); +} diff --git a/packages/react/src/tour/demo/Primary.tsx b/packages/react/src/tour/demo/Primary.tsx new file mode 100644 index 000000000..eb3f75881 --- /dev/null +++ b/packages/react/src/tour/demo/Primary.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Tour, Button } from '@tiny-design/react'; +import type { TourStepProps } from '../../tour/types'; + +export default function PrimaryDemo() { + const [open, setOpen] = React.useState(false); + const ref1 = React.useRef(null); + const ref2 = React.useRef(null); + + const steps: TourStepProps[] = [ + { + title: 'Upload File', + description: 'Put your files here.', + target: () => ref1.current, + }, + { + title: 'Save', + description: 'Save your changes.', + target: () => ref2.current, + placement: 'top', + }, + ]; + + return ( + <> + + Upload + Save + setOpen(true)}> + Start Tour + + + setOpen(false)} /> + > + ); +} diff --git a/packages/react/src/tour/index.md b/packages/react/src/tour/index.md new file mode 100644 index 000000000..4a6b02ba6 --- /dev/null +++ b/packages/react/src/tour/index.md @@ -0,0 +1,160 @@ +import BasicDemo from './demo/Basic'; +import BasicSource from './demo/Basic.tsx?raw'; +import PlacementDemo from './demo/Placement'; +import PlacementSource from './demo/Placement.tsx?raw'; +import PrimaryDemo from './demo/Primary'; +import PrimarySource from './demo/Primary.tsx?raw'; +import NoTargetDemo from './demo/NoTarget'; +import NoTargetSource from './demo/NoTarget.tsx?raw'; +import CustomIndicatorDemo from './demo/CustomIndicator'; +import CustomIndicatorSource from './demo/CustomIndicator.tsx?raw'; +import CoverDemo from './demo/Cover'; +import CoverSource from './demo/Cover.tsx?raw'; +import NoMaskDemo from './demo/NoMask'; +import NoMaskSource from './demo/NoMask.tsx?raw'; +import CustomButtonDemo from './demo/CustomButton'; +import CustomButtonSource from './demo/CustomButton.tsx?raw'; +import GapDemo from './demo/Gap'; +import GapSource from './demo/Gap.tsx?raw'; + +# Tour + +A popup component for guiding users through a product. + +## Scenario + +Use **Tour** to guide users through a product's features step by step, highlighting elements on the page with a popover card and a spotlight mask. + +## Usage + +```jsx +import { Tour } from '@tiny-design/react'; +``` + +## Examples + + + + + +### Basic + +Basic usage of Tour. Click the button to start a guided tour. + + + + + + +### Primary Type + +Use `type="primary"` for a colored tour card. + + + + + + +### Cover Image + +Add a `cover` to display an image or video at the top of the step card. + + + + + + +### Custom Gap + +Use `gap` to adjust the padding and border radius of the spotlight area around the target. + + + + + + + + +### Placement + +Customize the placement of the tour card relative to the target element. + + + + + + +### Without Target + +When a step has no `target`, the card appears centered on the screen. + + + + + + +### Without Mask + +Set `mask={false}` to hide the overlay mask. + + + + + + +### Custom Indicator + +Use `indicatorsRender` to customize the step indicator. + + + + + + +### Custom Button Text + +Use `nextButtonProps` and `prevButtonProps` to customize the navigation button text. + + + + + + + +## Tour Props + +| Property | Description | Type | Default | +| --------------------- | -------------------------------------------------------- | ----------------------------------------------------- | -------- | +| open | whether the tour is open | boolean | false | +| current | current step index (controlled) | number | - | +| steps | tour steps config | TourStepProps[] | [] | +| placement | default placement for all steps | Placement | `bottom` | +| arrow | whether to show the arrow | boolean | true | +| mask | whether to show the mask overlay | boolean | true | +| disabledInteraction | disable interaction on highlighted area | boolean | false | +| type | visual type | `default` | `primary` | `default`| +| gap | gap between spotlight and target | `{ offset?: number; radius?: number }` | `{ offset: 6, radius: 2 }` | +| zIndex | z-index of the tour layer | number | 1001 | +| keyboard | enable keyboard navigation | boolean | true | +| scrollIntoViewOptions | scroll into view options | boolean | ScrollIntoViewOptions | true | +| indicatorsRender | custom indicator renderer | (current: number, total: number) => ReactNode | - | +| onChange | callback when step changes | (current: number) => void | - | +| onClose | callback when tour is closed | () => void | - | +| onFinish | callback when tour finishes | () => void | - | + +## TourStepProps + +| Property | Description | Type | Default | +| --------------------- | -------------------------------------------------------- | ----------------------------------------------------- | -------- | +| target | element the step points to | HTMLElement | (() => HTMLElement | null) | - | +| title | step title | ReactNode | - | +| description | step description | ReactNode | - | +| cover | cover image or video | ReactNode | - | +| placement | placement relative to target | Placement | `bottom` | +| arrow | whether to show the arrow | boolean | true | +| mask | whether to show the mask | boolean | true | +| disabledInteraction | disable interaction on highlighted area | boolean | false | +| nextButtonProps | props for the next button | `{ children?: ReactNode; onClick?: () => void }` | - | +| prevButtonProps | props for the previous button | `{ children?: ReactNode; onClick?: () => void }` | - | +| scrollIntoViewOptions | custom scroll behavior | boolean | ScrollIntoViewOptions | true | +| onClose | callback when this step is closed | () => void | - | diff --git a/packages/react/src/tour/index.tsx b/packages/react/src/tour/index.tsx new file mode 100644 index 000000000..f7c369f68 --- /dev/null +++ b/packages/react/src/tour/index.tsx @@ -0,0 +1,4 @@ +import Tour from './tour'; + +export default Tour; +export type { TourProps, TourStepProps } from './types'; diff --git a/packages/react/src/tour/index.zh_CN.md b/packages/react/src/tour/index.zh_CN.md new file mode 100644 index 000000000..be4dcf256 --- /dev/null +++ b/packages/react/src/tour/index.zh_CN.md @@ -0,0 +1,160 @@ +import BasicDemo from './demo/Basic'; +import BasicSource from './demo/Basic.tsx?raw'; +import PlacementDemo from './demo/Placement'; +import PlacementSource from './demo/Placement.tsx?raw'; +import PrimaryDemo from './demo/Primary'; +import PrimarySource from './demo/Primary.tsx?raw'; +import NoTargetDemo from './demo/NoTarget'; +import NoTargetSource from './demo/NoTarget.tsx?raw'; +import CustomIndicatorDemo from './demo/CustomIndicator'; +import CustomIndicatorSource from './demo/CustomIndicator.tsx?raw'; +import CoverDemo from './demo/Cover'; +import CoverSource from './demo/Cover.tsx?raw'; +import NoMaskDemo from './demo/NoMask'; +import NoMaskSource from './demo/NoMask.tsx?raw'; +import CustomButtonDemo from './demo/CustomButton'; +import CustomButtonSource from './demo/CustomButton.tsx?raw'; +import GapDemo from './demo/Gap'; +import GapSource from './demo/Gap.tsx?raw'; + +# Tour 漫游式引导 + +用于分步引导用户了解产品功能的气泡组件。 + +## 使用场景 + +使用 **Tour** 组件可以分步引导用户了解产品功能,通过气泡卡片和聚光灯遮罩高亮页面上的元素。 + +## 引入 + +```jsx +import { Tour } from '@tiny-design/react'; +``` + +## 代码演示 + + + + + +### 基本用法 + +Tour 的基本用法。点击按钮开始引导。 + + + + + + +### 主题色类型 + +使用 `type="primary"` 展示主题色引导卡片。 + + + + + + +### 封面图片 + +通过 `cover` 在步骤卡片顶部展示图片或视频。 + + + + + + +### 自定义间距 + +使用 `gap` 调整聚光灯区域与目标元素之间的间距和圆角。 + + + + + + + + +### 位置 + +自定义引导卡片相对于目标元素的位置。 + + + + + + +### 无目标元素 + +当步骤没有 `target` 时,卡片将居中显示在屏幕上。 + + + + + + +### 无遮罩 + +设置 `mask={false}` 隐藏遮罩层。 + + + + + + +### 自定义指示器 + +使用 `indicatorsRender` 自定义步骤指示器。 + + + + + + +### 自定义按钮文案 + +使用 `nextButtonProps` 和 `prevButtonProps` 自定义导航按钮文案。 + + + + + + + +## Tour 属性 + +| 属性 | 说明 | 类型 | 默认值 | +| --------------------- | -------------------------------------------------------- | ----------------------------------------------------- | -------- | +| open | 是否打开引导 | boolean | false | +| current | 当前步骤索引(受控) | number | - | +| steps | 引导步骤配置 | TourStepProps[] | [] | +| placement | 所有步骤的默认位置 | Placement | `bottom` | +| arrow | 是否显示箭头 | boolean | true | +| mask | 是否显示遮罩层 | boolean | true | +| disabledInteraction | 是否禁用高亮区域的交互 | boolean | false | +| type | 引导类型 | `default` | `primary` | `default`| +| gap | 聚光灯与目标元素之间的间距 | `{ offset?: number; radius?: number }` | `{ offset: 6, radius: 2 }` | +| zIndex | Tour 层的 z-index | number | 1001 | +| keyboard | 是否启用键盘导航 | boolean | true | +| scrollIntoViewOptions | 滚动到可视区域选项 | boolean | ScrollIntoViewOptions | true | +| indicatorsRender | 自定义指示器渲染 | (current: number, total: number) => ReactNode | - | +| onChange | 步骤变化时的回调 | (current: number) => void | - | +| onClose | 关闭引导时的回调 | () => void | - | +| onFinish | 引导完成时的回调 | () => void | - | + +## TourStepProps 属性 + +| 属性 | 说明 | 类型 | 默认值 | +| --------------------- | -------------------------------------------------------- | ----------------------------------------------------- | -------- | +| target | 步骤指向的元素 | HTMLElement | (() => HTMLElement | null) | - | +| title | 步骤标题 | ReactNode | - | +| description | 步骤描述 | ReactNode | - | +| cover | 封面图片或视频 | ReactNode | - | +| placement | 相对于目标的位置 | Placement | `bottom` | +| arrow | 是否显示箭头 | boolean | true | +| mask | 是否显示遮罩 | boolean | true | +| disabledInteraction | 是否禁用高亮区域的交互 | boolean | false | +| nextButtonProps | 下一步按钮属性 | `{ children?: ReactNode; onClick?: () => void }` | - | +| prevButtonProps | 上一步按钮属性 | `{ children?: ReactNode; onClick?: () => void }` | - | +| scrollIntoViewOptions | 自定义滚动行为 | boolean | ScrollIntoViewOptions | true | +| onClose | 关闭此步骤时的回调 | () => void | - | diff --git a/packages/react/src/tour/style/_index.scss b/packages/react/src/tour/style/_index.scss new file mode 100644 index 000000000..c216a2b55 --- /dev/null +++ b/packages/react/src/tour/style/_index.scss @@ -0,0 +1,260 @@ +@use 'sass:math'; +@use '@tiny-design/tokens/scss/variables' as *; + +$arrow-size: 8px; + +.#{$prefix}-tour { + position: fixed; + inset: 0; + pointer-events: none; + + // Mask + &__mask { + position: fixed; + inset: 0; + pointer-events: auto; + } + + &__mask-svg { + width: 100%; + height: 100%; + } + + &__mask-block { + pointer-events: auto; + cursor: not-allowed; + } + + // Arrow + &__arrow, + &__arrow::before { + width: $arrow-size * 2; + height: $arrow-size * 2; + box-sizing: border-box; + position: absolute; + } + + &__arrow::before { + content: ''; + transform: rotate(45deg); + } + + // Panel wrapper (Popper target) + &__panel-wrapper { + position: absolute; + pointer-events: auto; + + &_centered { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + + // Panel + &__panel { + position: relative; + border-radius: $popover-border-radius; + box-shadow: var(--ty-shadow-modal); + max-width: 360px; + min-width: 260px; + + &_default { + background-color: var(--ty-popup-light-bg); + color: var(--ty-color-text); + + .#{$prefix}-tour { + &__arrow::before { + background: var(--ty-popup-light-bg); + box-shadow: 2px 2px 5px var(--ty-popup-arrow-shadow); + } + + &__description { + color: var(--ty-color-text-secondary); + } + } + } + + &_primary { + background-color: var(--ty-color-primary); + color: #fff; + + .#{$prefix}-tour { + &__arrow::before { + background: var(--ty-color-primary); + } + + &__description { + color: rgb(255 255 255 / 85%); + } + + &__indicator { + background-color: rgb(255 255 255 / 35%); + + &_active { + background-color: #fff; + } + } + + &__close-btn { + color: rgb(255 255 255 / 65%); + + &:hover { + color: #fff; + background-color: rgb(255 255 255 / 15%); + } + } + } + } + + // Zoom transition + &_zoom { + &-enter { + opacity: 0; + transform: scale(0.9); + } + + &-enter-active { + opacity: 1; + transform: scale(1); + transition: opacity 200ms ease-out, transform 200ms ease-out; + } + + &-enter-done { + opacity: 1; + transform: scale(1); + } + + &-exit { + opacity: 1; + transform: scale(1); + } + + &-exit-active { + opacity: 0; + transform: scale(0.9); + transition: opacity 200ms ease-in, transform 200ms ease-in; + } + + &-exit-done { + opacity: 0; + display: none; + } + } + } + + // Close button + &__close-btn { + position: absolute; + top: 8px; + right: 8px; + width: 28px; + height: 28px; + line-height: 28px; + text-align: center; + cursor: pointer; + background: none; + border: none; + border-radius: 4px; + padding: 0; + font-size: 12px; + color: var(--ty-color-text-tertiary); + + &:hover { + background-color: var(--ty-color-fill-secondary); + } + } + + // Cover + &__cover { + padding: 12px 16px 0; + text-align: center; + + img { + max-width: 100%; + border-radius: $popover-border-radius $popover-border-radius 0 0; + } + } + + // Title + &__title { + padding: 12px 16px 4px; + font-weight: 600; + font-size: var(--ty-font-size-lg); + line-height: 1.5; + } + + // Description + &__description { + padding: 0 16px 12px; + font-size: var(--ty-font-size-base); + line-height: 1.5; + } + + // Footer + &__footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px 12px; + } + + // Indicators + &__indicators { + display: flex; + gap: 4px; + } + + &__indicator { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--ty-color-text-quaternary); + transition: background-color 200ms; + + &_active { + background-color: var(--ty-color-primary); + } + } + + // Actions + &__actions { + display: flex; + gap: 8px; + margin-left: auto; + } + + // Arrow placement + [data-popper-placement^='top'] > &__arrow { + bottom: -$arrow-size; + + &::before { + box-shadow: 3px 3px 7px var(--ty-popup-arrow-shadow); + } + } + + [data-popper-placement^='bottom'] > &__arrow { + top: -$arrow-size; + + &::before { + box-shadow: -2px -2px 5px var(--ty-popup-arrow-shadow); + } + } + + [data-popper-placement^='left'] > &__arrow { + right: -$arrow-size; + + &::before { + box-shadow: 3px -3px 7px var(--ty-popup-arrow-shadow); + } + } + + [data-popper-placement^='right'] > &__arrow { + left: -$arrow-size; + + &::before { + box-shadow: -3px 3px 7px var(--ty-popup-arrow-shadow); + } + } +} diff --git a/packages/react/src/tour/style/index.tsx b/packages/react/src/tour/style/index.tsx new file mode 100644 index 000000000..38da75dc0 --- /dev/null +++ b/packages/react/src/tour/style/index.tsx @@ -0,0 +1,4 @@ +import './index.scss'; + +// style dependencies +import '../../button/style/index.scss'; diff --git a/packages/react/src/tour/tour-mask.tsx b/packages/react/src/tour/tour-mask.tsx new file mode 100644 index 000000000..7172a9de3 --- /dev/null +++ b/packages/react/src/tour/tour-mask.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +export interface TourMaskProps { + maskId: string; + targetRect: DOMRect | null; + zIndex: number; + gap: { offset: number; radius: number }; + disabledInteraction: boolean; + prefixCls: string; + onClick?: (e: React.MouseEvent) => void; +} + +const TRANSITION = 'all 300ms ease-in-out'; + +const TourMask = React.forwardRef((props, ref) => { + const { maskId, targetRect, zIndex, gap, disabledInteraction, prefixCls, onClick } = props; + + const cutoutStyle: React.CSSProperties | undefined = targetRect + ? { + x: targetRect.left - gap.offset, + y: targetRect.top - gap.offset, + width: targetRect.width + gap.offset * 2, + height: targetRect.height + gap.offset * 2, + rx: gap.radius, + ry: gap.radius, + transition: TRANSITION, + } as React.CSSProperties + : undefined; + + return ( + + + + + + {targetRect && } + + + + {targetRect && disabledInteraction && ( + + )} + + + ); +}); + +TourMask.displayName = 'TourMask'; + +export default TourMask; diff --git a/packages/react/src/tour/tour-panel.tsx b/packages/react/src/tour/tour-panel.tsx new file mode 100644 index 000000000..df49e4a86 --- /dev/null +++ b/packages/react/src/tour/tour-panel.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import Button from '../button/button'; +import { TourStepProps } from './types'; + +export interface TourPanelProps { + step: TourStepProps; + current: number; + total: number; + type: 'default' | 'primary'; + prefixCls: string; + prevButtonText: string; + nextButtonText: string; + finishButtonText: string; + indicatorsRender?: (current: number, total: number) => React.ReactNode; + onPrev: () => void; + onNext: () => void; + onClose: () => void; +} + +const TourPanel = React.forwardRef((props, ref) => { + const { + step, + current, + total, + type, + prefixCls, + prevButtonText, + nextButtonText, + finishButtonText, + indicatorsRender, + onPrev, + onNext, + onClose, + } = props; + const isLast = current === total - 1; + const isFirst = current === 0; + + const handlePrev = () => { + step.prevButtonProps?.onClick?.(); + onPrev(); + }; + + const handleNext = () => { + step.nextButtonProps?.onClick?.(); + onNext(); + }; + + const renderIndicators = () => { + if (indicatorsRender) { + return indicatorsRender(current, total); + } + return ( + + {Array.from({ length: total }, (_, i) => ( + + ))} + + ); + }; + + return ( + + + ✕ + + {step.cover && {step.cover}} + {step.title && {step.title}} + {step.description && {step.description}} + + {renderIndicators()} + + {!isFirst && ( + + {step.prevButtonProps?.children ?? prevButtonText} + + )} + + {step.nextButtonProps?.children ?? (isLast ? finishButtonText : nextButtonText)} + + + + + ); +}); + +TourPanel.displayName = 'TourPanel'; + +export default TourPanel; diff --git a/packages/react/src/tour/tour.tsx b/packages/react/src/tour/tour.tsx new file mode 100644 index 000000000..24be46368 --- /dev/null +++ b/packages/react/src/tour/tour.tsx @@ -0,0 +1,301 @@ +import React, { useCallback, useContext, useEffect, useId, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { createPopper, Instance } from '@popperjs/core'; +import Portal from '../portal'; +import Transition from '../transition'; +import { ConfigContext } from '../config-provider/config-context'; +import { getPrefixCls } from '../_utils/general'; +import { useLocale } from '../_utils/use-locale'; +import { Placement } from '../popup/types'; +import TourMask from './tour-mask'; +import TourPanel from './tour-panel'; +import { TourProps, TourStepProps } from './types'; + +const DEFAULT_GAP = { offset: 6, radius: 2 }; + +function resolveTarget(target: TourStepProps['target']): HTMLElement | null { + if (!target) return null; + if (typeof target === 'function') return target(); + return target; +} + +const Tour = React.forwardRef((props, ref) => { + const { + open = false, + current: currentProp, + steps = [], + placement: globalPlacement = 'bottom', + arrow: globalArrow = true, + mask: globalMask = true, + disabledInteraction: globalDisabledInteraction = false, + type = 'default', + gap: gapProp, + zIndex = 1001, + keyboard = true, + scrollIntoViewOptions = true, + indicatorsRender, + onChange, + onClose, + onFinish, + className, + style, + prefixCls: customisedCls, + } = props; + const configContext = useContext(ConfigContext); + const locale = useLocale(); + const prefixCls = getPrefixCls('tour', configContext.prefixCls, customisedCls); + const cls = classNames(prefixCls, className); + const gap = { ...DEFAULT_GAP, ...gapProp }; + const maskId = useId(); + + const [internalCurrent, setInternalCurrent] = useState(0); + const current = currentProp ?? internalCurrent; + const [targetRect, setTargetRect] = useState(null); + const [panelVisible, setPanelVisible] = useState(false); + + const panelRef = useRef(null); + const popperRef = useRef(null); + + const currentStep = steps[current]; + const stepPlacement = currentStep?.placement ?? globalPlacement; + const stepArrow = currentStep?.arrow ?? globalArrow; + const stepMask = currentStep?.mask ?? globalMask; + const stepDisabledInteraction = currentStep?.disabledInteraction ?? globalDisabledInteraction; + const stepScrollIntoView = currentStep?.scrollIntoViewOptions ?? scrollIntoViewOptions; + + const destroyPopper = useCallback(() => { + if (popperRef.current) { + popperRef.current.destroy(); + popperRef.current = null; + } + }, []); + + const createPopperInstance = useCallback(() => { + destroyPopper(); + + const target = resolveTarget(currentStep?.target); + const panel = panelRef.current; + if (!target || !panel || !target.isConnected) return; + + popperRef.current = createPopper(target, panel, { + placement: stepPlacement as Placement, + modifiers: [ + { name: 'flip', enabled: true }, + { + name: 'offset', + options: { + offset: [0, stepArrow ? 16 + gap.offset : 8 + gap.offset], + }, + }, + { + name: 'computeStyles', + options: { gpuAcceleration: false, adaptive: false }, + }, + ], + }); + }, [currentStep?.target, stepPlacement, stepArrow, gap.offset, destroyPopper]); + + const updateTargetRect = useCallback(() => { + const target = resolveTarget(currentStep?.target); + if (target) { + setTargetRect(target.getBoundingClientRect()); + } else { + setTargetRect(null); + } + }, [currentStep?.target]); + + // Update target rect and scroll into view when step changes + useEffect(() => { + if (!open) return undefined; + + const target = resolveTarget(currentStep?.target); + + // Update rect immediately so mask/panel move without delay + updateTargetRect(); + setPanelVisible(true); + + // If scrolling is needed, scroll and re-measure after scroll settles + if (target && stepScrollIntoView) { + const rect = target.getBoundingClientRect(); + const isInViewport = + rect.top >= 0 && + rect.bottom <= window.innerHeight && + rect.left >= 0 && + rect.right <= window.innerWidth; + + if (!isInViewport) { + const scrollOptions = + typeof stepScrollIntoView === 'object' ? stepScrollIntoView : { behavior: 'smooth' as const, block: 'center' as const }; + target.scrollIntoView(scrollOptions); + const timer = setTimeout(() => updateTargetRect(), 300); + return () => clearTimeout(timer); + } + } + + return undefined; + }, [open, current, currentStep?.target, stepScrollIntoView, updateTargetRect]); + + // Recreate Popper when the step changes (target, placement, etc.) + useEffect(() => { + if (!open || !panelVisible) return; + // Use rAF to ensure the panel DOM is committed and painted + const raf = requestAnimationFrame(() => { + createPopperInstance(); + }); + return () => cancelAnimationFrame(raf); + }, [open, panelVisible, current, createPopperInstance]); + + // Update rect on scroll/resize, and also update Popper position + useEffect(() => { + if (!open) return undefined; + + const handleUpdate = () => { + updateTargetRect(); + popperRef.current?.update(); + }; + window.addEventListener('scroll', handleUpdate, true); + window.addEventListener('resize', handleUpdate); + return () => { + window.removeEventListener('scroll', handleUpdate, true); + window.removeEventListener('resize', handleUpdate); + }; + }, [open, updateTargetRect]); + + const handleTransitionExited = useCallback(() => { + destroyPopper(); + }, [destroyPopper]); + + // Cleanup on unmount + useEffect(() => { + return () => destroyPopper(); + }, [destroyPopper]); + + // Reset state when closed + useEffect(() => { + if (!open) { + setPanelVisible(false); + setTargetRect(null); + destroyPopper(); + if (currentProp === undefined) { + setInternalCurrent(0); + } + } + }, [open, currentProp, destroyPopper]); + + // Scroll lock + useEffect(() => { + if (!open) return undefined; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + // Keyboard navigation + useEffect(() => { + if (!open || !keyboard) return undefined; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + currentStep?.onClose?.(); + onClose?.(); + } else if (e.key === 'ArrowLeft' && current > 0) { + const next = current - 1; + if (currentProp === undefined) setInternalCurrent(next); + onChange?.(next); + } else if (e.key === 'ArrowRight' && current < steps.length - 1) { + const next = current + 1; + if (currentProp === undefined) setInternalCurrent(next); + onChange?.(next); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [open, keyboard, current, steps.length, currentProp, currentStep, onChange, onClose]); + + const handlePrev = () => { + if (current > 0) { + const next = current - 1; + if (currentProp === undefined) setInternalCurrent(next); + onChange?.(next); + } + }; + + const handleNext = () => { + if (current < steps.length - 1) { + const next = current + 1; + if (currentProp === undefined) setInternalCurrent(next); + onChange?.(next); + } else { + onFinish?.(); + onClose?.(); + } + }; + + const handleClose = () => { + currentStep?.onClose?.(); + onClose?.(); + }; + + if (!open || !currentStep) return null; + + const hasTarget = !!resolveTarget(currentStep.target); + const ariaLabel = typeof currentStep.title === 'string' ? currentStep.title : 'Tour'; + + return ( + + + {stepMask && ( + + )} + + + e.stopPropagation()}> + {stepArrow && hasTarget && ( + + )} + + + + + + ); +}); + +Tour.displayName = 'Tour'; + +export default Tour; diff --git a/packages/react/src/tour/types.ts b/packages/react/src/tour/types.ts new file mode 100644 index 000000000..990e069af --- /dev/null +++ b/packages/react/src/tour/types.ts @@ -0,0 +1,97 @@ +import React from 'react'; +import { BaseProps } from '../_utils/props'; +import { Placement } from '../popup/types'; + +export interface TourStepProps { + /** The element the tour step points to */ + target?: HTMLElement | (() => HTMLElement | null) | null; + + /** Step title */ + title?: React.ReactNode; + + /** Step description */ + description?: React.ReactNode; + + /** Cover image or video */ + cover?: React.ReactNode; + + /** Position of the step card relative to the target */ + placement?: Placement; + + /** Whether to show the arrow */ + arrow?: boolean; + + /** Whether to show the mask */ + mask?: boolean; + + /** Whether to disable interaction on the highlighted area */ + disabledInteraction?: boolean; + + /** Props for the next button */ + nextButtonProps?: { + children?: React.ReactNode; + onClick?: () => void; + }; + + /** Props for the previous button */ + prevButtonProps?: { + children?: React.ReactNode; + onClick?: () => void; + }; + + /** Custom scroll behavior */ + scrollIntoViewOptions?: boolean | ScrollIntoViewOptions; + + /** Callback when this step is closed */ + onClose?: () => void; +} + +export interface TourProps extends BaseProps { + /** Whether the tour is open */ + open?: boolean; + + /** Current step index (controlled) */ + current?: number; + + /** Tour steps */ + steps?: TourStepProps[]; + + /** Default placement for all steps */ + placement?: Placement; + + /** Whether to show arrows */ + arrow?: boolean; + + /** Whether to show the mask overlay */ + mask?: boolean; + + /** Whether to disable interaction on the highlighted area */ + disabledInteraction?: boolean; + + /** Visual type */ + type?: 'default' | 'primary'; + + /** Gap between the spotlight and the target element */ + gap?: { offset?: number; radius?: number }; + + /** Z-index of the tour layer */ + zIndex?: number; + + /** Enable keyboard navigation (Escape to close, arrow keys to navigate) */ + keyboard?: boolean; + + /** Scroll into view options */ + scrollIntoViewOptions?: boolean | ScrollIntoViewOptions; + + /** Custom indicator renderer */ + indicatorsRender?: (current: number, total: number) => React.ReactNode; + + /** Callback when the current step changes */ + onChange?: (current: number) => void; + + /** Callback when the tour is closed */ + onClose?: () => void; + + /** Callback after the tour finishes (last step next button clicked) */ + onFinish?: () => void; +}