Skip to content

Commit 1063df2

Browse files
committed
[-]: 키보드 및 닫기 제어 옵션 추가
1 parent 39ea3b2 commit 1063df2

6 files changed

Lines changed: 217 additions & 3 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ const App = () => {
5151
options: {
5252
infoBoxHeight: 220,
5353
infoBoxMargin: 24,
54+
keyboardNavigation: true,
55+
closeOnOverlayClick: true,
5456
onClose: () => {
5557
console.log('tutorial closed');
5658
},
@@ -71,6 +73,16 @@ const App = () => {
7173

7274
`content` is rendered as a plain string. HTML markup in the string is not interpreted.
7375

76+
Keyboard navigation is enabled by default while the overlay is open:
77+
78+
- `Escape` closes the tutorial.
79+
- `ArrowRight` moves to the next step and completes the tutorial on the last step.
80+
- `ArrowLeft` moves to the previous step and is a no-op on the first step.
81+
82+
Set `options.keyboardNavigation` to `false` to disable those shortcuts. Shortcuts are ignored while an `input`, `textarea`, `select`, or `contenteditable` element has focus.
83+
84+
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.
85+
7486
Mount `<TutorialOverlay />` once near the root of your app, then trigger `tutorial.open({ steps, options })` from any event handler or effect.
7587

7688
## Documentation

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,7 @@ function App() {
2323
```
2424

2525
`TutorialOverlay` does not need props for the current public API. Configure behavior through `tutorial.open({ steps, options })`.
26+
27+
By default, the mounted overlay listens for `Escape`, `ArrowLeft`, and `ArrowRight` while it is open. You can disable that with `options.keyboardNavigation = false`.
28+
29+
Backdrop clicks are ignored by default. Set `options.closeOnOverlayClick = true` if you want clicking the dimmed overlay area to close the tutorial.

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ function App() {
2828
options: {
2929
infoBoxHeight: 220,
3030
infoBoxMargin: 24,
31+
keyboardNavigation: true,
32+
closeOnOverlayClick: true,
3133
},
3234
});
3335
}, []);
@@ -61,4 +63,8 @@ function App() {
6163

6264
- `infoBoxHeight`: sets the info box height in pixels.
6365
- `infoBoxMargin`: controls the vertical gap between the target and the info box.
66+
- `keyboardNavigation`: enables `Escape`, `ArrowLeft`, and `ArrowRight` shortcuts while the overlay is open. Defaults to `true`.
67+
- `closeOnOverlayClick`: closes the tutorial when the backdrop itself is clicked. Defaults to `false`.
6468
- `onClose`: runs when the tutorial is closed.
69+
70+
Keyboard shortcuts are ignored while an `input`, `textarea`, `select`, or `contenteditable` element has focus.

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

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ElementStyle, Options } from '../core/types';
33
import { useTutorialStore } from '../core/store';
44
import { setup, styled } from 'goober';
55
import { Content } from './content';
6+
import { tutorial } from '../core/tutorial';
67

78
setup(React.createElement);
89

@@ -21,6 +22,21 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
2122
const infoBoxElement = useRef<HTMLDivElement>(null);
2223
const timeout = useRef<number | undefined>();
2324

25+
function shouldIgnoreKeyboardEvent(): boolean {
26+
const activeElement = document.activeElement;
27+
if (!(activeElement instanceof HTMLElement)) {
28+
return false;
29+
}
30+
31+
const tagName = activeElement.tagName;
32+
return (
33+
activeElement.isContentEditable ||
34+
tagName === 'INPUT' ||
35+
tagName === 'TEXTAREA' ||
36+
tagName === 'SELECT'
37+
);
38+
}
39+
2440
function resetHighlightedElements(): void {
2541
currentElements.current.forEach((item) => {
2642
item.element.classList.remove('foreground');
@@ -131,6 +147,40 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
131147
setHighlightedElementPositions();
132148
}, [steps, index]);
133149

150+
useEffect(() => {
151+
if (!open || options?.keyboardNavigation === false) {
152+
return;
153+
}
154+
155+
function handleKeyDown(event: KeyboardEvent) {
156+
if (shouldIgnoreKeyboardEvent()) {
157+
return;
158+
}
159+
160+
switch (event.key) {
161+
case 'Escape':
162+
event.preventDefault();
163+
tutorial.close();
164+
break;
165+
case 'ArrowRight':
166+
event.preventDefault();
167+
tutorial.next();
168+
break;
169+
case 'ArrowLeft':
170+
event.preventDefault();
171+
tutorial.prev();
172+
break;
173+
default:
174+
break;
175+
}
176+
}
177+
178+
window.addEventListener('keydown', handleKeyDown);
179+
return () => {
180+
window.removeEventListener('keydown', handleKeyDown);
181+
};
182+
}, [open, options?.keyboardNavigation]);
183+
134184
useEffect(() => {
135185
function handleResize() {
136186
clearTimeout(timeout.current);
@@ -146,11 +196,21 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
146196
};
147197
}, [steps, index, options]);
148198

199+
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
200+
if (options?.closeOnOverlayClick && event.target === event.currentTarget) {
201+
tutorial.close();
202+
}
203+
};
204+
149205
return open ? (
150-
<Wrapper>
206+
<Wrapper data-testid="tutorial-overlay-backdrop" onClick={handleBackdropClick}>
151207
<Content ref={infoBoxElement as RefObject<HTMLInputElement>} />
152208
{rectStyles.map((style) => (
153-
<Hightlight key={style.id} style={style} />
209+
<Hightlight
210+
data-testid={`tutorial-overlay-highlight-${style.id}`}
211+
key={style.id}
212+
style={style}
213+
/>
154214
))}
155215
</Wrapper>
156216
) : null;

packages/main/src/core/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export interface Options {
1111
highLightPadding?: number;
1212
infoBoxHeight?: number;
1313
infoBoxMargin?: number;
14+
keyboardNavigation?: boolean;
15+
closeOnOverlayClick?: boolean;
1416
onClose?: () => void;
1517
}
1618

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

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,39 @@
11
import React from 'react';
2-
import { act, render, screen } from '@testing-library/react';
2+
import { act, fireEvent, render, screen } from '@testing-library/react';
33
import { TutorialOverlay } from '../src/components/tutorial-overlay';
44
import { tutorial } from '../src/core/tutorial';
5+
import type { Options } from '../src/core/types';
6+
7+
function renderOverlay() {
8+
render(
9+
<div>
10+
<input aria-label="Page input" />
11+
<div id="first-target">First target</div>
12+
<div id="second-target">Second target</div>
13+
<TutorialOverlay />
14+
</div>
15+
);
16+
}
17+
18+
function openTutorial(options: Options = {}) {
19+
act(() => {
20+
tutorial.open({
21+
steps: [
22+
{
23+
title: 'Step 1',
24+
content: 'Step 1 content',
25+
targetIds: ['first-target'],
26+
},
27+
{
28+
title: 'Step 2',
29+
content: 'Step 2 content',
30+
targetIds: ['second-target'],
31+
},
32+
],
33+
options,
34+
});
35+
});
36+
}
537

638
describe('TutorialOverlay', () => {
739
test('stays mounted when a target element cannot be found', () => {
@@ -27,4 +59,102 @@ describe('TutorialOverlay', () => {
2759
expect(screen.getByText('1 / 1')).toBeInTheDocument();
2860
expect(errorSpy).toHaveBeenCalledWith('Highlighted element with id does-not-exist was not found.');
2961
});
62+
63+
test('supports keyboard navigation by default', () => {
64+
const onClose = jest.fn();
65+
66+
renderOverlay();
67+
openTutorial({ onClose });
68+
69+
fireEvent.keyDown(window, { key: 'ArrowRight' });
70+
71+
expect(screen.getByText('Step 2 content')).toBeInTheDocument();
72+
73+
fireEvent.keyDown(window, { key: 'ArrowLeft' });
74+
75+
expect(screen.getByText('Step 1 content')).toBeInTheDocument();
76+
77+
fireEvent.keyDown(window, { key: 'Escape' });
78+
79+
expect(onClose).toHaveBeenCalledTimes(1);
80+
expect(screen.queryByText('Step 1 content')).not.toBeInTheDocument();
81+
});
82+
83+
test('keeps first step on ArrowLeft and closes after ArrowRight on the last step', () => {
84+
const onClose = jest.fn();
85+
86+
renderOverlay();
87+
openTutorial({ onClose });
88+
89+
fireEvent.keyDown(window, { key: 'ArrowLeft' });
90+
91+
expect(screen.getByText('Step 1 content')).toBeInTheDocument();
92+
93+
fireEvent.keyDown(window, { key: 'ArrowRight' });
94+
fireEvent.keyDown(window, { key: 'ArrowRight' });
95+
96+
expect(onClose).toHaveBeenCalledTimes(1);
97+
expect(screen.queryByText('Step 2 content')).not.toBeInTheDocument();
98+
});
99+
100+
test('does not handle keyboard shortcuts when keyboardNavigation is disabled', () => {
101+
const onClose = jest.fn();
102+
103+
renderOverlay();
104+
openTutorial({ keyboardNavigation: false, onClose });
105+
106+
fireEvent.keyDown(window, { key: 'ArrowRight' });
107+
fireEvent.keyDown(window, { key: 'Escape' });
108+
109+
expect(screen.getByText('Step 1 content')).toBeInTheDocument();
110+
expect(onClose).not.toHaveBeenCalled();
111+
});
112+
113+
test('ignores keyboard shortcuts while a text input has focus', () => {
114+
const onClose = jest.fn();
115+
116+
renderOverlay();
117+
openTutorial({ onClose });
118+
119+
const input = screen.getByLabelText('Page input');
120+
input.focus();
121+
122+
fireEvent.keyDown(input, { key: 'ArrowRight' });
123+
fireEvent.keyDown(input, { key: 'Escape' });
124+
125+
expect(screen.getByText('Step 1 content')).toBeInTheDocument();
126+
expect(onClose).not.toHaveBeenCalled();
127+
});
128+
129+
test('closes on backdrop click only when closeOnOverlayClick is enabled', () => {
130+
const onClose = jest.fn();
131+
132+
renderOverlay();
133+
openTutorial({ closeOnOverlayClick: true, onClose });
134+
135+
fireEvent.click(screen.getByTestId('tutorial-overlay-highlight-first-target'));
136+
137+
expect(screen.getByText('Step 1 content')).toBeInTheDocument();
138+
139+
fireEvent.click(screen.getByText('Step 1 content'));
140+
141+
expect(screen.getByText('Step 1 content')).toBeInTheDocument();
142+
143+
fireEvent.click(screen.getByTestId('tutorial-overlay-backdrop'));
144+
145+
expect(onClose).toHaveBeenCalledTimes(1);
146+
expect(screen.queryByText('Step 1 content')).not.toBeInTheDocument();
147+
});
148+
149+
test('does not close on backdrop click by default', () => {
150+
const onClose = jest.fn();
151+
152+
renderOverlay();
153+
openTutorial({ onClose });
154+
155+
fireEvent.click(screen.getByTestId('tutorial-overlay-backdrop'));
156+
157+
expect(screen.getByText('Step 1 content')).toBeInTheDocument();
158+
expect(onClose).not.toHaveBeenCalled();
159+
});
30160
});

0 commit comments

Comments
 (0)