Skip to content

Commit 0e5eeb9

Browse files
authored
Merge pull request #7 from sjsjsj1246/codex/add-highlight-padding-and-overlay-click-behavior
[-]: highLightPadding 반영 및 overlay 위치 보정
2 parents bbc6560 + 50ab82a commit 0e5eeb9

6 files changed

Lines changed: 213 additions & 16 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const App = () => {
4949
},
5050
],
5151
options: {
52+
highLightPadding: 12,
5253
infoBoxHeight: 220,
5354
infoBoxMargin: 24,
5455
keyboardNavigation: true,
@@ -73,6 +74,8 @@ const App = () => {
7374

7475
`content` is rendered as a plain string. HTML markup in the string is not interpreted.
7576

77+
`highLightPadding` expands the highlight frame around the target element. It defaults to `8` pixels and applies to the rendered highlight box as well as the info box anchor position.
78+
7679
Keyboard navigation is enabled by default while the overlay is open:
7780

7881
- `Escape` closes the tutorial.
@@ -83,6 +86,8 @@ Set `options.keyboardNavigation` to `false` to disable those shortcuts. Shortcut
8386

8487
Set `options.closeOnOverlayClick` to `true` to close the tutorial when the dimmed backdrop itself is clicked. Clicks on the highlight frame and info box do not trigger close.
8588

89+
The info box automatically flips and clamps itself to stay inside the viewport when the target sits close to an edge.
90+
8691
Mount `<TutorialOverlay />` once near the root of your app, then trigger `tutorial.open({ steps, options })` from any event handler or effect.
8792

8893
## Documentation

packages/document/src/pages/docs/tutorial-overlay.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ function App() {
2424

2525
`TutorialOverlay` does not need props for the current public API. Configure behavior through `tutorial.open({ steps, options })`.
2626

27+
The highlight frame uses `options.highLightPadding` to expand around the target. If you do not provide it, the overlay uses an `8px` padding by default.
28+
2729
By default, the mounted overlay listens for `Escape`, `ArrowLeft`, and `ArrowRight` while it is open. You can disable that with `options.keyboardNavigation = false`.
2830

2931
Backdrop clicks are ignored by default. Set `options.closeOnOverlayClick = true` if you want clicking the dimmed overlay area to close the tutorial.
32+
33+
The info box automatically flips and clamps itself to stay visible when the highlighted target sits near the viewport edges.

packages/document/src/pages/docs/tutorial.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ function App() {
2626
},
2727
],
2828
options: {
29+
highLightPadding: 12,
2930
infoBoxHeight: 220,
3031
infoBoxMargin: 24,
3132
keyboardNavigation: true,
@@ -61,10 +62,12 @@ function App() {
6162

6263
## Tutorial options
6364

65+
- `highLightPadding`: expands the highlight frame around the target in pixels. Defaults to `8`.
6466
- `infoBoxHeight`: sets the info box height in pixels.
6567
- `infoBoxMargin`: controls the vertical gap between the target and the info box.
6668
- `keyboardNavigation`: enables `Escape`, `ArrowLeft`, and `ArrowRight` shortcuts while the overlay is open. Defaults to `true`.
6769
- `closeOnOverlayClick`: closes the tutorial when the backdrop itself is clicked. Defaults to `false`.
6870
- `onClose`: runs when the tutorial is closed.
6971

7072
Keyboard shortcuts are ignored while an `input`, `textarea`, `select`, or `contenteditable` element has focus.
73+
The info box automatically repositions itself to stay within the viewport when the target is close to an edge.

packages/main/src/components/tutorial-overlay.tsx

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { tutorial } from '../core/tutorial';
77

88
setup(React.createElement);
99

10+
const DEFAULT_HIGHLIGHT_PADDING = 8;
11+
const MIN_VIEWPORT_OFFSET = 10;
12+
1013
interface TutorialOverlayProps {
1114
options?: Options;
1215
}
@@ -44,6 +47,18 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
4447
currentElements.current = [];
4548
}
4649

50+
function getHighlightPadding(): number {
51+
return Math.max(0, options?.highLightPadding ?? DEFAULT_HIGHLIGHT_PADDING);
52+
}
53+
54+
function clamp(value: number, min: number, max: number): number {
55+
if (max < min) {
56+
return min;
57+
}
58+
59+
return Math.min(Math.max(value, min), max);
60+
}
61+
4762
function setHighlightedElementPositions() {
4863
const stepConfig = steps[index];
4964
const elementIds = stepConfig?.targetIds;
@@ -58,12 +73,14 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
5873
}[] = [];
5974

6075
const alreadyCalculated = elementIds[0] === currentElements.current[0]?.id;
76+
const highlightPadding = getHighlightPadding();
77+
let infoBoxAnchor: ElementStyle | null = null;
6178

6279
if (!alreadyCalculated) {
6380
resetHighlightedElements();
6481
}
6582

66-
elementIds.forEach((id: string, index: number) => {
83+
elementIds.forEach((id: string) => {
6784
const element: HTMLElement | null = document.getElementById(id);
6885
if (!element) {
6986
console.error(`Highlighted element with id ${id} was not found.`);
@@ -85,17 +102,23 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
85102
if (selectedElPosition) {
86103
const position: ElementStyle = {
87104
id: id,
88-
left: selectedElPosition.left + window.scrollX - 1,
89-
top: selectedElPosition.top + window.scrollY - 1,
90-
width: selectedElPosition.width + 2,
91-
height: selectedElPosition.height + 2,
105+
left: selectedElPosition.left + window.scrollX - highlightPadding,
106+
top: selectedElPosition.top + window.scrollY - highlightPadding,
107+
width: selectedElPosition.width + highlightPadding * 2,
108+
height: selectedElPosition.height + highlightPadding * 2,
109+
borderRadius: Math.max(10, highlightPadding + 2),
92110
};
93111
positions.push(position);
94-
if (index === 0) {
95-
calculateInfoBoxPosition(position, stepConfig.infoBoxAlignment);
112+
if (!infoBoxAnchor) {
113+
infoBoxAnchor = position;
96114
}
97115
}
98116
});
117+
118+
if (infoBoxAnchor) {
119+
calculateInfoBoxPosition(infoBoxAnchor, stepConfig.infoBoxAlignment);
120+
}
121+
99122
if (currentElements.current.length === 0 || !alreadyCalculated) {
100123
currentElements.current = elements;
101124
}
@@ -106,25 +129,47 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
106129
function calculateInfoBoxPosition(position: ElementStyle, alignment?: 'center' | 'left' | 'right') {
107130
const boxHeight = options?.infoBoxHeight ?? 200;
108131
const margin = options?.infoBoxMargin ?? 30;
132+
const minLeft = window.scrollX + MIN_VIEWPORT_OFFSET;
133+
const maxLeft = window.scrollX + window.innerWidth - MIN_VIEWPORT_OFFSET;
134+
const minTop = window.scrollY + MIN_VIEWPORT_OFFSET;
135+
const maxTop = window.scrollY + window.innerHeight - MIN_VIEWPORT_OFFSET;
109136

110137
let newBoxTop = position.top - boxHeight - margin;
111-
if (newBoxTop < 10) {
112-
newBoxTop = position.top + position.height + margin;
113-
}
138+
const fallbackBoxTop = position.top + position.height + margin;
114139

115140
const el = infoBoxElement.current;
116141
if (el) {
142+
el.style.height = boxHeight + 'px';
143+
144+
const boxWidth = el.getBoundingClientRect().width || el.clientWidth;
117145
let newBoxLeft: number;
146+
118147
if (alignment === 'left') {
119-
newBoxLeft = position.left < 10 ? 10 : position.left;
148+
newBoxLeft = position.left;
120149
} else if (alignment === 'right') {
121-
newBoxLeft = position.left + position.width - el.clientWidth;
150+
newBoxLeft = position.left + position.width - boxWidth;
122151
} else {
123152
newBoxLeft = position.left + position.width / 2;
124-
const halfOfBoxWidth = el.clientWidth / 2;
125-
newBoxLeft = newBoxLeft - halfOfBoxWidth < 10 ? 10 + halfOfBoxWidth : newBoxLeft;
153+
const halfOfBoxWidth = boxWidth / 2;
154+
newBoxLeft = clamp(newBoxLeft, minLeft + halfOfBoxWidth, maxLeft - halfOfBoxWidth);
126155
}
127-
el.style.height = boxHeight + 'px';
156+
157+
if (alignment !== 'center') {
158+
newBoxLeft = clamp(newBoxLeft, minLeft, maxLeft - boxWidth);
159+
}
160+
161+
const maxBoxTop = maxTop - boxHeight;
162+
if (newBoxTop < minTop) {
163+
newBoxTop = fallbackBoxTop;
164+
}
165+
if (newBoxTop > maxBoxTop) {
166+
if (position.top - boxHeight - margin >= minTop) {
167+
newBoxTop = position.top - boxHeight - margin;
168+
} else {
169+
newBoxTop = clamp(newBoxTop, minTop, maxBoxTop);
170+
}
171+
}
172+
128173
el.style.top = newBoxTop + 'px';
129174
el.style.left = newBoxLeft + 'px';
130175
el.style.transform = alignment === 'center' ? 'translate(-50%)' : '';
@@ -229,7 +274,7 @@ const Wrapper = styled('div')`
229274
const Hightlight = styled('div')`
230275
position: absolute;
231276
z-index: 9999;
277+
box-sizing: border-box;
232278
border: 2px solid #ff0000;
233279
border-radius: 0.625rem;
234-
transform: translate(-1px, -1px);
235280
`;

packages/main/src/core/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ export interface ElementStyle {
2727
top: number;
2828
width: number;
2929
height: number;
30+
borderRadius?: number;
3031
}

packages/main/test/tutorial-overlay.test.tsx

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { TutorialOverlay } from '../src/components/tutorial-overlay';
44
import { tutorial } from '../src/core/tutorial';
55
import type { Options } from '../src/core/types';
66

7+
const DEFAULT_HIGHLIGHT_PADDING = 8;
8+
79
function renderOverlay() {
810
render(
911
<div>
@@ -35,6 +37,29 @@ function openTutorial(options: Options = {}) {
3537
});
3638
}
3739

40+
function createDomRect({ left, top, width, height }: { left: number; top: number; width: number; height: number }): DOMRect {
41+
return {
42+
x: left,
43+
y: top,
44+
left,
45+
top,
46+
width,
47+
height,
48+
right: left + width,
49+
bottom: top + height,
50+
toJSON: () => '',
51+
} as DOMRect;
52+
}
53+
54+
function mockTargetRect(id: string, rect: { left: number; top: number; width: number; height: number }) {
55+
const element = document.getElementById(id) as HTMLElement;
56+
element.getBoundingClientRect = jest.fn(() => createDomRect(rect));
57+
}
58+
59+
function getInfoBoxElement(): HTMLDivElement {
60+
return screen.getByText('Step 1').closest('div')?.parentElement?.parentElement as HTMLDivElement;
61+
}
62+
3863
describe('TutorialOverlay', () => {
3964
test('stays mounted when a target element cannot be found', () => {
4065
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
@@ -157,4 +182,118 @@ describe('TutorialOverlay', () => {
157182
expect(screen.getByText('Step 1 content')).toBeInTheDocument();
158183
expect(onClose).not.toHaveBeenCalled();
159184
});
185+
186+
test('applies the default highlight padding to the calculated rect', () => {
187+
renderOverlay();
188+
mockTargetRect('first-target', { left: 100, top: 80, width: 120, height: 40 });
189+
190+
openTutorial();
191+
192+
expect(screen.getByTestId('tutorial-overlay-highlight-first-target')).toHaveStyle({
193+
left: `${100 - DEFAULT_HIGHLIGHT_PADDING}px`,
194+
top: `${80 - DEFAULT_HIGHLIGHT_PADDING}px`,
195+
width: `${120 + DEFAULT_HIGHLIGHT_PADDING * 2}px`,
196+
height: `${40 + DEFAULT_HIGHLIGHT_PADDING * 2}px`,
197+
});
198+
});
199+
200+
test('applies a custom highlight padding to the calculated rect', () => {
201+
renderOverlay();
202+
mockTargetRect('first-target', { left: 200, top: 160, width: 80, height: 32 });
203+
204+
openTutorial({ highLightPadding: 16 });
205+
206+
expect(screen.getByTestId('tutorial-overlay-highlight-first-target')).toHaveStyle({
207+
left: '184px',
208+
top: '144px',
209+
width: '112px',
210+
height: '64px',
211+
});
212+
});
213+
214+
test('keeps the info box inside the viewport when padding expands the target rect', () => {
215+
jest.useFakeTimers();
216+
Object.defineProperty(window, 'innerWidth', {
217+
configurable: true,
218+
value: 800,
219+
});
220+
Object.defineProperty(window, 'innerHeight', {
221+
configurable: true,
222+
value: 600,
223+
});
224+
225+
renderOverlay();
226+
mockTargetRect('first-target', { left: 760, top: 100, width: 40, height: 40 });
227+
228+
act(() => {
229+
tutorial.open({
230+
steps: [
231+
{
232+
title: 'Step 1',
233+
content: 'Step 1 content',
234+
targetIds: ['first-target'],
235+
infoBoxAlignment: 'right',
236+
},
237+
],
238+
options: {
239+
highLightPadding: 8,
240+
},
241+
});
242+
});
243+
244+
const infoBox = getInfoBoxElement();
245+
Object.defineProperty(infoBox, 'clientWidth', {
246+
configurable: true,
247+
value: 320,
248+
});
249+
Object.defineProperty(infoBox, 'clientHeight', {
250+
configurable: true,
251+
value: 200,
252+
});
253+
254+
act(() => {
255+
window.dispatchEvent(new Event('resize'));
256+
jest.advanceTimersByTime(300);
257+
});
258+
259+
expect(infoBox.style.left).toBe('470px');
260+
261+
jest.useRealTimers();
262+
});
263+
264+
test('clamps the info box vertically when neither side has enough space', () => {
265+
jest.useFakeTimers();
266+
Object.defineProperty(window, 'innerWidth', {
267+
configurable: true,
268+
value: 800,
269+
});
270+
Object.defineProperty(window, 'innerHeight', {
271+
configurable: true,
272+
value: 600,
273+
});
274+
275+
renderOverlay();
276+
mockTargetRect('first-target', { left: 120, top: 100, width: 80, height: 40 });
277+
278+
openTutorial({ infoBoxHeight: 520 });
279+
280+
const infoBox = getInfoBoxElement();
281+
Object.defineProperty(infoBox, 'clientWidth', {
282+
configurable: true,
283+
value: 320,
284+
});
285+
Object.defineProperty(infoBox, 'clientHeight', {
286+
configurable: true,
287+
value: 520,
288+
});
289+
290+
act(() => {
291+
window.dispatchEvent(new Event('resize'));
292+
jest.advanceTimersByTime(300);
293+
});
294+
295+
expect(infoBox.style.top).toBe('70px');
296+
297+
jest.useRealTimers();
298+
});
160299
});

0 commit comments

Comments
 (0)