Skip to content

Commit a01c68f

Browse files
committed
[-]: Promise API 추가
1 parent 692baeb commit a01c68f

11 files changed

Lines changed: 221 additions & 56 deletions

File tree

README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ A React library for step-by-step product tutorials with an imperative overlay AP
1313
- ✨ step-by-step tutorial overlay
1414
- 🎯 highlight one or more DOM targets per step
1515
- 🕹 imperative controls via `tutorial.open()`, `next()`, `prev()`, and `close()`
16+
- ⏳ await tutorial completion with a small Promise result payload
1617
- 📦 minimal setup with a single `<TutorialOverlay />` mount
1718

1819
## Get Started
@@ -33,8 +34,8 @@ yarn add react-tutorial-overlay
3334
import { TutorialOverlay, tutorial } from 'react-tutorial-overlay';
3435

3536
const App = () => {
36-
const handleClick = () => {
37-
tutorial.open({
37+
const handleClick = async () => {
38+
const result = await tutorial.open({
3839
steps: [
3940
{
4041
targetIds: ['target1'],
@@ -59,6 +60,10 @@ const App = () => {
5960
},
6061
},
6162
});
63+
64+
if (result.reason === 'completed') {
65+
console.log('continue onboarding flow');
66+
}
6267
};
6368

6469
return (
@@ -72,6 +77,12 @@ const App = () => {
7277
};
7378
```
7479

80+
`tutorial.open()` returns `Promise<{ reason: 'completed' | 'skipped' | 'closed' }>`:
81+
82+
- `completed`: the user finished the last step.
83+
- `skipped`: the user clicked the built-in `건너뛰기` button.
84+
- `closed`: the tutorial was closed externally, such as `tutorial.close()`, `Escape`, backdrop click, or opening a new tutorial while another promise is still pending.
85+
7586
`content` is rendered as a plain string. HTML markup in the string is not interpreted.
7687

7788
`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.
@@ -88,6 +99,8 @@ Set `options.closeOnOverlayClick` to `true` to close the tutorial when the dimme
8899

89100
The info box automatically flips and clamps itself to stay inside the viewport when the target sits close to an edge.
90101

102+
`options.onClose` still runs whenever the tutorial closes. Use the returned Promise when you need async flow control after the tutorial ends.
103+
91104
Mount `<TutorialOverlay />` once near the root of your app, then trigger `tutorial.open({ steps, options })` from any event handler or effect.
92105

93106
## Documentation

docs/plans/2026-03-24-project-restart.md

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
- 완료: `codex/fix-docs-lint` 병합 완료
1818
- 완료: `codex/align-readme-and-docs` 병합 완료
1919
- 완료: `codex/add-keyboard-and-close-controls` 병합 완료
20+
- 완료: `codex/add-highlight-padding-and-overlay-click-behavior` 병합 완료
21+
- 완료: `codex/add-promise-api` 병합 완료
2022
- 반영된 내용:
2123
- `packages/main/test/setup.ts` 추가
2224
- `packages/main/jest.config.js` 정상화
@@ -41,6 +43,11 @@
4143
- `input` / `textarea` / `select` / `contenteditable` 포커스 중 단축키 무시 처리 추가
4244
- backdrop click close를 opt-in 동작으로 추가하고 highlight / info box 클릭과 구분
4345
- keyboard / overlay close 관련 README 및 docs 예제 업데이트
46+
- `tutorial.open()``Promise<{ reason: 'completed' | 'skipped' | 'closed' }>`를 반환하도록 확장
47+
- 마지막 step 완료, built-in 건너뛰기, 외부 close 경로별 resolve reason 구분 추가
48+
- 새 tutorial open 시 이전 pending promise를 `closed`로 정리하도록 보정
49+
- Promise API와 `onClose`의 역할을 분리하고 기존 callback 계약 유지
50+
- README 및 docs에 async / await 사용 예제 추가
4451
- 현재 기준선:
4552
- `pnpm -C packages/main test` 통과
4653
- `pnpm -C packages/main test:coverage` 통과
@@ -66,23 +73,9 @@
6673

6774
## Next Product Work Candidates
6875

69-
첫 기능 확장 작업으로 권장했던 `codex/add-keyboard-and-close-controls` 완료 및 병합됐다.
76+
첫 기능 확장 작업으로 권장했던 `codex/add-keyboard-and-close-controls`, `codex/add-highlight-padding-and-overlay-click-behavior`, `codex/add-promise-api`는 모두 완료 및 병합됐다.
7077

71-
다음으로 바로 붙이기 좋은 기능 후보는 아래 2개다.
72-
73-
1. `codex/add-highlight-padding-and-overlay-click-behavior`
74-
2. `codex/add-promise-api`
75-
76-
이제 추천 시작점은 `codex/add-highlight-padding-and-overlay-click-behavior`다.
77-
78-
- `Options.highLightPadding`가 타입에는 있지만 실제 highlight 계산에는 반영되지 않는다.
79-
- overlay 클릭 시 닫을지 여부, highlight 영역 바깥 상호작용을 어떻게 막을지 같은 핵심 UX 정책이 아직 없다.
80-
81-
그 다음 우선순위는 Promise API다.
82-
83-
- README에 예고만 있었고 아직 실제 API가 없다.
84-
- `tutorial.open()`이 완료 시점을 resolve하는 Promise를 반환하면 튜토리얼 종료 후 후속 로직 연결이 쉬워진다.
85-
- 다만 상태 계약을 한 번 더 설계해야 해서 앞의 두 작업보다 약간 무겁다.
78+
이 문서는 현재까지 반영된 후속 기능 이력을 남기는 참고 문서로 유지한다. 다음 신규 기능 후보는 별도 계획 문서에서 다시 우선순위를 잡는 편이 맞다.
8679

8780
## Common Setup For Every Worktree
8881

@@ -384,15 +377,14 @@ Expected:
384377

385378
**Suggested worktree:** `codex/add-promise-api`
386379

387-
**Status:** 다음 추천 작업
380+
**Status:** 완료 및 병합됨
388381

389382
**Goal:** 튜토리얼 완료/취소 시점을 소비자가 await할 수 있도록 Promise 기반 API를 추가한다.
390383

391-
**Why later:**
392-
- 가장 제품 가치가 크지만 상태 전이와 close reason 설계가 필요하다.
393-
- `tutorial.open()` 반환형 변경과 문서화가 뒤따른다.
394-
395-
**Proposed scope:**
396-
- `tutorial.open()`이 Promise를 반환
397-
- 완료 / 건너뛰기 / 강제 종료를 구분하는 resolve payload 정의
398-
- docs와 examples에 async usage 추가
384+
**Result:**
385+
- `tutorial.open()``Promise<{ reason: 'completed' | 'skipped' | 'closed' }>`를 반환하도록 변경
386+
- 마지막 step 완료 시 `completed`, built-in `건너뛰기` 버튼 경로에서 `skipped`, 외부 `tutorial.close()` / `Escape` / backdrop close / replacement open 경로에서 `closed` resolve 추가
387+
- pending promise가 한 번만 settle되도록 중복 resolve 방지
388+
- 새 tutorial을 열면 기존 pending tutorial을 `closed`로 정리하고 기존 `options.onClose`도 계속 실행되도록 유지
389+
- `packages/main/test/tutorial.test.tsx``packages/main/test/content.test.tsx`에 Promise API 회귀 테스트 추가
390+
- README 및 docs landing/tutorial/tutorial-overlay 문서에 async usage 예제와 reason 계약 반영

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ yarn add react-tutorial-overlay
2424
import { TutorialOverlay, tutorial } from 'react-tutorial-overlay';
2525

2626
const App = () => {
27-
const handleClick = () => {
28-
tutorial.open({
27+
const handleClick = async () => {
28+
const result = await tutorial.open({
2929
steps: [
3030
{
3131
targetIds: ['target-id'],
@@ -39,6 +39,10 @@ const App = () => {
3939
},
4040
},
4141
});
42+
43+
if (result.reason === 'completed') {
44+
console.log('move to the next onboarding task');
45+
}
4246
};
4347

4448
return (
@@ -54,4 +58,6 @@ const App = () => {
5458

5559
Mount `<TutorialOverlay />` once in your app. Then call `tutorial.open({ steps, options })` from any button handler or effect.
5660

61+
`tutorial.open()` resolves with `{ reason: 'completed' | 'skipped' | 'closed' }` so you can await the end of the tutorial.
62+
5763
`content` accepts a string. HTML inside that string is shown as text rather than injected into the page.

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

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

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

27+
Because `tutorial.open()` returns a Promise, you can keep `<TutorialOverlay />` mounted once and await tutorial completion from any handler or effect that triggers the open call.
28+
2729
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.
2830

2931
By default, the mounted overlay listens for `Escape`, `ArrowLeft`, and `ArrowRight` while it is open. You can disable that with `options.keyboardNavigation = false`.

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

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,29 @@ import { TutorialOverlay, tutorial } from 'react-tutorial-overlay';
1616

1717
function App() {
1818
useEffect(() => {
19-
tutorial.open({
20-
steps: [
21-
{
22-
targetIds: ['target-id'],
23-
title: 'Welcome',
24-
content: 'This message is plain text.',
25-
infoBoxAlignment: 'center',
19+
async function runTutorial() {
20+
const result = await tutorial.open({
21+
steps: [
22+
{
23+
targetIds: ['target-id'],
24+
title: 'Welcome',
25+
content: 'This message is plain text.',
26+
infoBoxAlignment: 'center',
27+
},
28+
],
29+
options: {
30+
highLightPadding: 12,
31+
infoBoxHeight: 220,
32+
infoBoxMargin: 24,
33+
keyboardNavigation: true,
34+
closeOnOverlayClick: true,
2635
},
27-
],
28-
options: {
29-
highLightPadding: 12,
30-
infoBoxHeight: 220,
31-
infoBoxMargin: 24,
32-
keyboardNavigation: true,
33-
closeOnOverlayClick: true,
34-
},
35-
});
36+
});
37+
38+
console.log(result.reason);
39+
}
40+
41+
runTutorial();
3642
}, []);
3743

3844
return (
@@ -45,12 +51,18 @@ function App() {
4551

4652
## Available methods
4753

48-
- `tutorial.open({ steps, options })`: opens a tutorial and starts at the first step by default.
54+
- `tutorial.open({ steps, options })`: opens a tutorial and starts at the first step by default. Returns `Promise<{ reason: 'completed' | 'skipped' | 'closed' }>` when that tutorial ends.
4955
- `tutorial.next()`: moves to the next step and closes the overlay after the last step.
5056
- `tutorial.prev()`: moves back one step when possible.
51-
- `tutorial.close()`: closes the overlay and runs `options.onClose` if provided.
57+
- `tutorial.close()`: closes the overlay and resolves the pending `tutorial.open()` promise with `reason: 'closed'`.
5258
- `tutorial.update(index, step)`: replaces a step definition while the tutorial is active.
5359

60+
## Promise result reasons
61+
62+
- `completed`: the last step was completed through `tutorial.next()` or the built-in `완료` button.
63+
- `skipped`: the built-in `건너뛰기` button was clicked.
64+
- `closed`: the tutorial was closed manually with `tutorial.close()`, `Escape`, backdrop click, or because a newer tutorial replaced a pending one.
65+
5466
## Step fields
5567

5668
- `targetIds`: array of element ids to highlight for the current step.
@@ -71,3 +83,4 @@ function App() {
7183

7284
Keyboard shortcuts are ignored while an `input`, `textarea`, `select`, or `contenteditable` element has focus.
7385
The info box automatically repositions itself to stay within the viewport when the target is close to an edge.
86+
`onClose` remains available for side effects, while the Promise returned by `tutorial.open()` is the async completion hook.

packages/main/src/components/content.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22
import { useTutorialStore } from '../core/store';
3-
import { tutorial } from '../core/tutorial';
3+
import { skipTutorial, tutorial } from '../core/tutorial';
44
import { styled } from 'goober';
55

66
export const Content = React.forwardRef((_, ref?: React.ForwardedRef<HTMLInputElement>) => {
@@ -19,7 +19,7 @@ export const Content = React.forwardRef((_, ref?: React.ForwardedRef<HTMLInputEl
1919
};
2020

2121
const handleClose = () => {
22-
tutorial.close();
22+
skipTutorial();
2323
};
2424

2525
return (

packages/main/src/core/store.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState } from 'react';
2-
import type { Step, Tutorial } from './types';
2+
import type { Step, Tutorial, TutorialResult, TutorialResultReason } from './types';
33

44
export enum ActionType {
55
OPEN,
@@ -11,7 +11,7 @@ export enum ActionType {
1111

1212
type Action =
1313
| { type: ActionType.OPEN; payload: State }
14-
| { type: ActionType.CLOSE }
14+
| { type: ActionType.CLOSE; payload: { reason: TutorialResultReason } }
1515
| { type: ActionType.NEXT }
1616
| { type: ActionType.PREV }
1717
| { type: ActionType.UPDATE; payload: { index: number; step: Step } };
@@ -31,17 +31,30 @@ const initialState: State = {
3131
},
3232
};
3333

34+
let pendingTutorialResolver: ((result: TutorialResult) => void) | null = null;
35+
36+
function settlePendingTutorial(result: TutorialResult) {
37+
if (!pendingTutorialResolver) {
38+
return;
39+
}
40+
41+
const resolve = pendingTutorialResolver;
42+
pendingTutorialResolver = null;
43+
resolve(result);
44+
}
45+
3446
const reducer = (state: State, action: Action): State => {
3547
switch (action.type) {
3648
case ActionType.OPEN:
3749
return action.payload;
3850
case ActionType.CLOSE:
51+
settlePendingTutorial({ reason: action.payload.reason });
3952
state.tutorial.options?.onClose?.();
4053
return initialState;
4154
case ActionType.NEXT:
4255
state.tutorial.steps[state.index]?.onNextStep?.();
4356
if (state.index === state.tutorial.steps.length - 1) {
44-
return reducer(state, { type: ActionType.CLOSE });
57+
return reducer(state, { type: ActionType.CLOSE, payload: { reason: 'completed' } });
4558
} else {
4659
return {
4760
...state,
@@ -82,6 +95,15 @@ export const dispatch = (action: Action) => {
8295
});
8396
};
8497

98+
export const getState = (): State => memoryState;
99+
100+
export const hasPendingTutorialResult = (): boolean => pendingTutorialResolver !== null;
101+
102+
export const createPendingTutorialResult = (): Promise<TutorialResult> =>
103+
new Promise((resolve) => {
104+
pendingTutorialResolver = resolve;
105+
});
106+
85107
export const useTutorialStore = (): State => {
86108
const [state, setState] = useState<State>(memoryState);
87109
useEffect(() => {

packages/main/src/core/tutorial.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,35 @@
1-
import { ActionType, State, dispatch } from './store';
2-
import type { Step, Tutorial } from './types';
1+
import {
2+
ActionType,
3+
State,
4+
createPendingTutorialResult,
5+
dispatch,
6+
getState,
7+
hasPendingTutorialResult,
8+
} from './store';
9+
import type { Step, Tutorial, TutorialResult } from './types';
10+
11+
const open = (tutorial: Tutorial, otherState?: Omit<State, 'tutorial'>): Promise<TutorialResult> => {
12+
if (getState().open || hasPendingTutorialResult()) {
13+
dispatch({ type: ActionType.CLOSE, payload: { reason: 'closed' } });
14+
}
15+
16+
const resultPromise = createPendingTutorialResult();
317

4-
const open = (tutorial: Tutorial, otherState?: Omit<State, 'tutorial'>) =>
518
dispatch({
619
type: ActionType.OPEN,
720
payload: { tutorial, index: otherState?.index ?? 0, open: otherState?.open ?? true },
821
});
9-
const close = () => dispatch({ type: ActionType.CLOSE });
22+
23+
return resultPromise;
24+
};
25+
const close = () => dispatch({ type: ActionType.CLOSE, payload: { reason: 'closed' } });
1026
const next = () => dispatch({ type: ActionType.NEXT });
1127
const prev = () => dispatch({ type: ActionType.PREV });
1228
const update = (index: number, step: Step) => dispatch({ type: ActionType.UPDATE, payload: { index, step } });
1329

30+
export const skipTutorial = () =>
31+
dispatch({ type: ActionType.CLOSE, payload: { reason: 'skipped' } });
32+
1433
export const tutorial = {
1534
open,
1635
close,

packages/main/src/core/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ export interface Tutorial {
2121
options?: Options;
2222
}
2323

24+
export type TutorialResultReason = 'completed' | 'skipped' | 'closed';
25+
26+
export interface TutorialResult {
27+
reason: TutorialResultReason;
28+
}
29+
2430
export interface ElementStyle {
2531
id: string;
2632
left: number;

packages/main/test/content.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,29 @@ describe('Content', () => {
6060
expect(screen.getByText('<strong>Literal HTML</strong>')).toBeInTheDocument();
6161
expect(document.querySelector('strong')).toBeNull();
6262
});
63+
64+
test('skip button resolves the tutorial promise with skipped', async () => {
65+
renderOverlay();
66+
const onClose = jest.fn();
67+
68+
let resultPromise;
69+
70+
act(() => {
71+
resultPromise = tutorial.open({
72+
steps: [
73+
{
74+
title: 'Closable step',
75+
content: 'Closable content',
76+
targetIds: ['first-target'],
77+
},
78+
],
79+
options: { onClose },
80+
});
81+
});
82+
83+
fireEvent.click(screen.getByRole('button', { name: '건너뛰기' }));
84+
85+
await expect(resultPromise).resolves.toEqual({ reason: 'skipped' });
86+
expect(onClose).toHaveBeenCalledTimes(1);
87+
});
6388
});

0 commit comments

Comments
 (0)