Skip to content

Commit 57c2f38

Browse files
authored
feat(react): add Tour component (#88)
Add a new Tour component for step-by-step product guides, similar to Ant Design's Tour. Features include SVG spotlight mask with animated transitions, Popper.js positioning, keyboard navigation, scroll into view, customizable indicators, cover images, and i18n support.
1 parent a0b3e45 commit 57c2f38

26 files changed

+1672
-0
lines changed

.changeset/add-tour-component.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tiny-design/react": minor
3+
---
4+
5+
Add Tour component for step-by-step product guides with spotlight mask, Popper.js positioning, keyboard navigation, and customizable step indicators

apps/docs/src/routers.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ const c = {
103103
textLoop: ll(() => import('../../../packages/react/src/text-loop/index.md'), () => import('../../../packages/react/src/text-loop/index.zh_CN.md')),
104104
timeline: ll(() => import('../../../packages/react/src/timeline/index.md'), () => import('../../../packages/react/src/timeline/index.zh_CN.md')),
105105
tooltip: ll(() => import('../../../packages/react/src/tooltip/index.md'), () => import('../../../packages/react/src/tooltip/index.zh_CN.md')),
106+
tour: ll(() => import('../../../packages/react/src/tour/index.md'), () => import('../../../packages/react/src/tour/index.zh_CN.md')),
106107
tree: ll(() => import('../../../packages/react/src/tree/index.md'), () => import('../../../packages/react/src/tree/index.zh_CN.md')),
107108
form: ll(() => import('../../../packages/react/src/form/index.md'), () => import('../../../packages/react/src/form/index.zh_CN.md')),
108109
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[] => {
300301
{ title: 'Modal', route: 'modal', component: pick(c.modal, z) },
301302
{ title: 'Notification', route: 'notification', component: pick(c.notification, z) },
302303
{ title: 'PopConfirm', route: 'pop-confirm', component: pick(c.popConfirm, z) },
304+
{ title: 'Tour', route: 'tour', component: pick(c.tour, z), tag: <Tag variant='soft' color="info">New</Tag> },
303305
{ title: 'Result', route: 'result', component: pick(c.result, z) },
304306
{ title: 'ScrollIndicator', route: 'scroll-indicator', component: pick(c.scrollIndicator, z) },
305307
{ title: 'Skeleton', route: 'skeleton', component: pick(c.skeleton, z) },

packages/react/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export { default as Textarea } from './textarea';
7777
export { default as Timeline } from './timeline';
7878
export { default as TimePicker } from './time-picker';
7979
export { default as Tooltip } from './tooltip';
80+
export { default as Tour } from './tour';
8081
export { default as Transfer } from './transfer';
8182
export { default as Transition } from './transition';
8283
export { default as Tree } from './tree';
@@ -92,3 +93,4 @@ export type { Locale } from './locale';
9293
export { useLocale } from './_utils/use-locale';
9394
export { useTheme } from './_utils/use-theme';
9495
export type { ThemeMode } from './_utils/use-theme';
96+
export type { TourProps, TourStepProps } from './tour';

packages/react/src/locale/en_US.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ const en_US: Locale = {
3939
rgb: 'RGB',
4040
hsb: 'HSB',
4141
},
42+
Tour: {
43+
prevText: 'Previous',
44+
nextText: 'Next',
45+
finishText: 'Finish',
46+
},
4247
};
4348

4449
export default en_US;

packages/react/src/locale/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,9 @@ export type Locale = {
3737
rgb: string;
3838
hsb: string;
3939
};
40+
Tour: {
41+
prevText: string;
42+
nextText: string;
43+
finishText: string;
44+
};
4045
};

packages/react/src/locale/zh_CN.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ const zh_CN: Locale = {
3939
rgb: 'RGB',
4040
hsb: 'HSB',
4141
},
42+
Tour: {
43+
prevText: '上一步',
44+
nextText: '下一步',
45+
finishText: '完成',
46+
},
4247
};
4348

4449
export default zh_CN;

packages/react/src/style/_component.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
@use "../timeline/style/index" as *;
7575
@use "../time-picker/style/index" as *;
7676
@use "../tooltip/style/index" as *;
77+
@use "../tour/style/index" as *;
7778
@use "../transfer/style/index" as *;
7879
@use "../transition/style/index" as *;
7980
@use "../tree/style/index" as *;
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import React from 'react';
2+
import { render, fireEvent } from '@testing-library/react';
3+
import Tour from '../index';
4+
import { TourStepProps } from '../types';
5+
6+
const steps: TourStepProps[] = [
7+
{ title: 'Step 1', description: 'Description 1' },
8+
{ title: 'Step 2', description: 'Description 2' },
9+
{ title: 'Step 3', description: 'Description 3' },
10+
];
11+
12+
describe('<Tour />', () => {
13+
it('should not render when open is false', () => {
14+
const { baseElement } = render(<Tour open={false} steps={steps} />);
15+
expect(baseElement.querySelector('.ty-tour')).not.toBeInTheDocument();
16+
});
17+
18+
it('should render when open is true', () => {
19+
const { getByText } = render(<Tour open steps={steps} />);
20+
expect(getByText('Step 1')).toBeInTheDocument();
21+
expect(getByText('Description 1')).toBeInTheDocument();
22+
});
23+
24+
it('should render step title and description', () => {
25+
const { getByText } = render(<Tour open steps={steps} />);
26+
expect(getByText('Step 1')).toBeInTheDocument();
27+
expect(getByText('Description 1')).toBeInTheDocument();
28+
});
29+
30+
it('should navigate to next step', () => {
31+
const onChange = jest.fn();
32+
const { getByText } = render(<Tour open steps={steps} onChange={onChange} />);
33+
fireEvent.click(getByText('Next'));
34+
expect(onChange).toHaveBeenCalledWith(1);
35+
});
36+
37+
it('should navigate to previous step', () => {
38+
const onChange = jest.fn();
39+
const { getByText } = render(<Tour open steps={steps} current={1} onChange={onChange} />);
40+
fireEvent.click(getByText('Previous'));
41+
expect(onChange).toHaveBeenCalledWith(0);
42+
});
43+
44+
it('should call onClose when close button is clicked', () => {
45+
const onClose = jest.fn();
46+
const { baseElement } = render(<Tour open steps={steps} onClose={onClose} />);
47+
const closeBtn = baseElement.querySelector('.ty-tour__close-btn') as HTMLElement;
48+
fireEvent.click(closeBtn);
49+
expect(onClose).toHaveBeenCalled();
50+
});
51+
52+
it('should call onFinish on last step next click', () => {
53+
const onFinish = jest.fn();
54+
const onClose = jest.fn();
55+
const { getByText } = render(
56+
<Tour open steps={steps} current={2} onFinish={onFinish} onClose={onClose} />
57+
);
58+
fireEvent.click(getByText('Finish'));
59+
expect(onFinish).toHaveBeenCalled();
60+
expect(onClose).toHaveBeenCalled();
61+
});
62+
63+
it('should render indicators', () => {
64+
const { baseElement } = render(<Tour open steps={steps} />);
65+
const indicators = baseElement.querySelectorAll('.ty-tour__indicator');
66+
expect(indicators).toHaveLength(3);
67+
});
68+
69+
it('should show active indicator for current step', () => {
70+
const { baseElement } = render(<Tour open steps={steps} current={1} />);
71+
const indicators = baseElement.querySelectorAll('.ty-tour__indicator');
72+
expect(indicators[1]).toHaveClass('ty-tour__indicator_active');
73+
});
74+
75+
it('should render mask by default', () => {
76+
const { baseElement } = render(<Tour open steps={steps} />);
77+
expect(baseElement.querySelector('.ty-tour__mask')).toBeInTheDocument();
78+
});
79+
80+
it('should not render mask when mask is false', () => {
81+
const { baseElement } = render(<Tour open steps={steps} mask={false} />);
82+
expect(baseElement.querySelector('.ty-tour__mask')).not.toBeInTheDocument();
83+
});
84+
85+
it('should render primary type', () => {
86+
const { baseElement } = render(<Tour open steps={steps} type="primary" />);
87+
expect(baseElement.querySelector('.ty-tour__panel_primary')).toBeInTheDocument();
88+
});
89+
90+
it('should handle keyboard Escape', () => {
91+
const onClose = jest.fn();
92+
render(<Tour open steps={steps} onClose={onClose} />);
93+
fireEvent.keyDown(document, { key: 'Escape' });
94+
expect(onClose).toHaveBeenCalled();
95+
});
96+
97+
it('should center panel when no target', () => {
98+
const { baseElement } = render(<Tour open steps={[{ title: 'Centered' }]} />);
99+
expect(
100+
baseElement.querySelector('.ty-tour__panel-wrapper_centered')
101+
).toBeInTheDocument();
102+
});
103+
104+
it('should not render previous button on first step', () => {
105+
const { queryByText } = render(<Tour open steps={steps} />);
106+
expect(queryByText('Previous')).not.toBeInTheDocument();
107+
});
108+
109+
it('should show Finish text on last step', () => {
110+
const { getByText } = render(<Tour open steps={steps} current={2} />);
111+
expect(getByText('Finish')).toBeInTheDocument();
112+
});
113+
114+
it('should support custom indicatorsRender', () => {
115+
const { getByText } = render(
116+
<Tour
117+
open
118+
steps={steps}
119+
indicatorsRender={(current, total) => <span>{`${current + 1}/${total}`}</span>}
120+
/>
121+
);
122+
expect(getByText('1/3')).toBeInTheDocument();
123+
});
124+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React from 'react';
2+
import { Tour, Button } from '@tiny-design/react';
3+
import type { TourStepProps } from '../../tour/types';
4+
5+
export default function BasicDemo() {
6+
const [open, setOpen] = React.useState(false);
7+
const ref1 = React.useRef<HTMLButtonElement>(null);
8+
const ref2 = React.useRef<HTMLButtonElement>(null);
9+
const ref3 = React.useRef<HTMLButtonElement>(null);
10+
11+
const steps: TourStepProps[] = [
12+
{
13+
title: 'Upload File',
14+
description: 'Put your files here.',
15+
target: () => ref1.current,
16+
},
17+
{
18+
title: 'Save',
19+
description: 'Save your changes.',
20+
target: () => ref2.current,
21+
},
22+
{
23+
title: 'Other Actions',
24+
description: 'Click to see other actions.',
25+
target: () => ref3.current,
26+
},
27+
];
28+
29+
return (
30+
<>
31+
<div style={{ display: 'flex', gap: 16 }}>
32+
<Button ref={ref1}>Upload</Button>
33+
<Button ref={ref2}>Save</Button>
34+
<Button ref={ref3} btnType="primary" onClick={() => setOpen(true)}>
35+
Start Tour
36+
</Button>
37+
</div>
38+
<Tour open={open} steps={steps} onClose={() => setOpen(false)} />
39+
</>
40+
);
41+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React from 'react';
2+
import { Tour, Button } from '@tiny-design/react';
3+
import type { TourStepProps } from '../../tour/types';
4+
5+
export default function CoverDemo() {
6+
const [open, setOpen] = React.useState(false);
7+
const ref1 = React.useRef<HTMLButtonElement>(null);
8+
const ref2 = React.useRef<HTMLButtonElement>(null);
9+
10+
const steps: TourStepProps[] = [
11+
{
12+
title: 'Create a project',
13+
description: 'Start by creating a new project from the dashboard.',
14+
cover: (
15+
<svg viewBox="0 0 300 120" style={{ width: '100%', borderRadius: '8px 8px 0 0' }}>
16+
<rect width="300" height="120" fill="#e6f4ff" />
17+
<circle cx="150" cy="50" r="25" fill="#1677ff" />
18+
<text x="150" y="100" textAnchor="middle" fill="#1677ff" fontSize="14">Step 1</text>
19+
</svg>
20+
),
21+
target: () => ref1.current,
22+
},
23+
{
24+
title: 'Upload files',
25+
description: 'Upload files to your project to get started.',
26+
cover: (
27+
<svg viewBox="0 0 300 120" style={{ width: '100%', borderRadius: '8px 8px 0 0' }}>
28+
<rect width="300" height="120" fill="#f6ffed" />
29+
<polygon points="150,25 175,75 125,75" fill="#52c41a" />
30+
<text x="150" y="100" textAnchor="middle" fill="#52c41a" fontSize="14">Step 2</text>
31+
</svg>
32+
),
33+
target: () => ref2.current,
34+
},
35+
];
36+
37+
return (
38+
<>
39+
<div style={{ display: 'flex', gap: 16 }}>
40+
<Button ref={ref1}>Create</Button>
41+
<Button ref={ref2} btnType="primary" onClick={() => setOpen(true)}>
42+
Start Tour
43+
</Button>
44+
</div>
45+
<Tour open={open} steps={steps} onClose={() => setOpen(false)} />
46+
</>
47+
);
48+
}

0 commit comments

Comments
 (0)