Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ A React library for step-by-step product tutorials with an imperative overlay AP
- ✨ step-by-step tutorial overlay
- 🎯 highlight one or more DOM targets per step
- 🕹 imperative controls via `tutorial.open()`, `next()`, `prev()`, and `close()`
- ⏳ await tutorial completion with a small Promise result payload
- 📦 minimal setup with a single `<TutorialOverlay />` mount

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

const App = () => {
const handleClick = () => {
tutorial.open({
const handleClick = async () => {
const result = await tutorial.open({
steps: [
{
targetIds: ['target1'],
Expand All @@ -59,6 +60,10 @@ const App = () => {
},
},
});

if (result.reason === 'completed') {
console.log('continue onboarding flow');
}
};

return (
Expand All @@ -72,6 +77,12 @@ const App = () => {
};
```

`tutorial.open()` returns `Promise<{ reason: 'completed' | 'skipped' | 'closed' }>`:

- `completed`: the user finished the last step.
- `skipped`: the user clicked the built-in `건너뛰기` button.
- `closed`: the tutorial was closed externally, such as `tutorial.close()`, `Escape`, backdrop click, or opening a new tutorial while another promise is still pending.

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

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

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

`options.onClose` still runs whenever the tutorial closes. Use the returned Promise when you need async flow control after the tutorial ends.

Mount `<TutorialOverlay />` once near the root of your app, then trigger `tutorial.open({ steps, options })` from any event handler or effect.

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

## Next Product Work Candidates

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

다음으로 바로 붙이기 좋은 기능 후보는 아래 2개다.

1. `codex/add-highlight-padding-and-overlay-click-behavior`
2. `codex/add-promise-api`

이제 추천 시작점은 `codex/add-highlight-padding-and-overlay-click-behavior`다.

- `Options.highLightPadding`가 타입에는 있지만 실제 highlight 계산에는 반영되지 않는다.
- overlay 클릭 시 닫을지 여부, highlight 영역 바깥 상호작용을 어떻게 막을지 같은 핵심 UX 정책이 아직 없다.

그 다음 우선순위는 Promise API다.

- README에 예고만 있었고 아직 실제 API가 없다.
- `tutorial.open()`이 완료 시점을 resolve하는 Promise를 반환하면 튜토리얼 종료 후 후속 로직 연결이 쉬워진다.
- 다만 상태 계약을 한 번 더 설계해야 해서 앞의 두 작업보다 약간 무겁다.
이 문서는 현재까지 반영된 후속 기능 이력을 남기는 참고 문서로 유지한다. 다음 신규 기능 후보는 별도 계획 문서에서 다시 우선순위를 잡는 편이 맞다.

## Common Setup For Every Worktree

Expand Down Expand Up @@ -384,15 +377,14 @@ Expected:

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

**Status:** 다음 추천 작업
**Status:** 완료 및 병합됨

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

**Why later:**
- 가장 제품 가치가 크지만 상태 전이와 close reason 설계가 필요하다.
- `tutorial.open()` 반환형 변경과 문서화가 뒤따른다.

**Proposed scope:**
- `tutorial.open()`이 Promise를 반환
- 완료 / 건너뛰기 / 강제 종료를 구분하는 resolve payload 정의
- docs와 examples에 async usage 추가
**Result:**
- `tutorial.open()`이 `Promise<{ reason: 'completed' | 'skipped' | 'closed' }>`를 반환하도록 변경
- 마지막 step 완료 시 `completed`, built-in `건너뛰기` 버튼 경로에서 `skipped`, 외부 `tutorial.close()` / `Escape` / backdrop close / replacement open 경로에서 `closed` resolve 추가
- pending promise가 한 번만 settle되도록 중복 resolve 방지
- 새 tutorial을 열면 기존 pending tutorial을 `closed`로 정리하고 기존 `options.onClose`도 계속 실행되도록 유지
- `packages/main/test/tutorial.test.tsx` 및 `packages/main/test/content.test.tsx`에 Promise API 회귀 테스트 추가
- README 및 docs landing/tutorial/tutorial-overlay 문서에 async usage 예제와 reason 계약 반영
10 changes: 8 additions & 2 deletions packages/document/src/pages/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ yarn add react-tutorial-overlay
import { TutorialOverlay, tutorial } from 'react-tutorial-overlay';

const App = () => {
const handleClick = () => {
tutorial.open({
const handleClick = async () => {
const result = await tutorial.open({
steps: [
{
targetIds: ['target-id'],
Expand All @@ -39,6 +39,10 @@ const App = () => {
},
},
});

if (result.reason === 'completed') {
console.log('move to the next onboarding task');
}
};

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

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

`tutorial.open()` resolves with `{ reason: 'completed' | 'skipped' | 'closed' }` so you can await the end of the tutorial.

`content` accepts a string. HTML inside that string is shown as text rather than injected into the page.
2 changes: 2 additions & 0 deletions packages/document/src/pages/docs/tutorial-overlay.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ function App() {

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

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.

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.

By default, the mounted overlay listens for `Escape`, `ArrowLeft`, and `ArrowRight` while it is open. You can disable that with `options.keyboardNavigation = false`.
Expand Down
49 changes: 31 additions & 18 deletions packages/document/src/pages/docs/tutorial.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,29 @@ import { TutorialOverlay, tutorial } from 'react-tutorial-overlay';

function App() {
useEffect(() => {
tutorial.open({
steps: [
{
targetIds: ['target-id'],
title: 'Welcome',
content: 'This message is plain text.',
infoBoxAlignment: 'center',
async function runTutorial() {
const result = await tutorial.open({
steps: [
{
targetIds: ['target-id'],
title: 'Welcome',
content: 'This message is plain text.',
infoBoxAlignment: 'center',
},
],
options: {
highLightPadding: 12,
infoBoxHeight: 220,
infoBoxMargin: 24,
keyboardNavigation: true,
closeOnOverlayClick: true,
},
],
options: {
highLightPadding: 12,
infoBoxHeight: 220,
infoBoxMargin: 24,
keyboardNavigation: true,
closeOnOverlayClick: true,
},
});
});

console.log(result.reason);
}

runTutorial();
}, []);

return (
Expand All @@ -45,12 +51,18 @@ function App() {

## Available methods

- `tutorial.open({ steps, options })`: opens a tutorial and starts at the first step by default.
- `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.
- `tutorial.next()`: moves to the next step and closes the overlay after the last step.
- `tutorial.prev()`: moves back one step when possible.
- `tutorial.close()`: closes the overlay and runs `options.onClose` if provided.
- `tutorial.close()`: closes the overlay and resolves the pending `tutorial.open()` promise with `reason: 'closed'`.
- `tutorial.update(index, step)`: replaces a step definition while the tutorial is active.

## Promise result reasons

- `completed`: the last step was completed through `tutorial.next()` or the built-in `완료` button.
- `skipped`: the built-in `건너뛰기` button was clicked.
- `closed`: the tutorial was closed manually with `tutorial.close()`, `Escape`, backdrop click, or because a newer tutorial replaced a pending one.

## Step fields

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

Keyboard shortcuts are ignored while an `input`, `textarea`, `select`, or `contenteditable` element has focus.
The info box automatically repositions itself to stay within the viewport when the target is close to an edge.
`onClose` remains available for side effects, while the Promise returned by `tutorial.open()` is the async completion hook.
4 changes: 2 additions & 2 deletions packages/main/src/components/content.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { useTutorialStore } from '../core/store';
import { tutorial } from '../core/tutorial';
import { skipTutorial, tutorial } from '../core/tutorial';
import { styled } from 'goober';

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

const handleClose = () => {
tutorial.close();
skipTutorial();
};

return (
Expand Down
28 changes: 25 additions & 3 deletions packages/main/src/core/store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import type { Step, Tutorial } from './types';
import type { Step, Tutorial, TutorialResult, TutorialResultReason } from './types';

export enum ActionType {
OPEN,
Expand All @@ -11,7 +11,7 @@ export enum ActionType {

type Action =
| { type: ActionType.OPEN; payload: State }
| { type: ActionType.CLOSE }
| { type: ActionType.CLOSE; payload: { reason: TutorialResultReason } }
| { type: ActionType.NEXT }
| { type: ActionType.PREV }
| { type: ActionType.UPDATE; payload: { index: number; step: Step } };
Expand All @@ -31,17 +31,30 @@ const initialState: State = {
},
};

let pendingTutorialResolver: ((result: TutorialResult) => void) | null = null;

function settlePendingTutorial(result: TutorialResult) {
if (!pendingTutorialResolver) {
return;
}

const resolve = pendingTutorialResolver;
pendingTutorialResolver = null;
resolve(result);
}

const reducer = (state: State, action: Action): State => {
switch (action.type) {
case ActionType.OPEN:
return action.payload;
case ActionType.CLOSE:
settlePendingTutorial({ reason: action.payload.reason });
state.tutorial.options?.onClose?.();
return initialState;
case ActionType.NEXT:
state.tutorial.steps[state.index]?.onNextStep?.();
if (state.index === state.tutorial.steps.length - 1) {
return reducer(state, { type: ActionType.CLOSE });
return reducer(state, { type: ActionType.CLOSE, payload: { reason: 'completed' } });
} else {
return {
...state,
Expand Down Expand Up @@ -82,6 +95,15 @@ export const dispatch = (action: Action) => {
});
};

export const getState = (): State => memoryState;

export const hasPendingTutorialResult = (): boolean => pendingTutorialResolver !== null;

export const createPendingTutorialResult = (): Promise<TutorialResult> =>
new Promise((resolve) => {
pendingTutorialResolver = resolve;
});

export const useTutorialStore = (): State => {
const [state, setState] = useState<State>(memoryState);
useEffect(() => {
Expand Down
27 changes: 23 additions & 4 deletions packages/main/src/core/tutorial.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
import { ActionType, State, dispatch } from './store';
import type { Step, Tutorial } from './types';
import {
ActionType,
State,
createPendingTutorialResult,
dispatch,
getState,
hasPendingTutorialResult,
} from './store';
import type { Step, Tutorial, TutorialResult } from './types';

const open = (tutorial: Tutorial, otherState?: Omit<State, 'tutorial'>): Promise<TutorialResult> => {
if (getState().open || hasPendingTutorialResult()) {
dispatch({ type: ActionType.CLOSE, payload: { reason: 'closed' } });
}

const resultPromise = createPendingTutorialResult();

const open = (tutorial: Tutorial, otherState?: Omit<State, 'tutorial'>) =>
dispatch({
type: ActionType.OPEN,
payload: { tutorial, index: otherState?.index ?? 0, open: otherState?.open ?? true },
});
const close = () => dispatch({ type: ActionType.CLOSE });

return resultPromise;
};
const close = () => dispatch({ type: ActionType.CLOSE, payload: { reason: 'closed' } });
const next = () => dispatch({ type: ActionType.NEXT });
const prev = () => dispatch({ type: ActionType.PREV });
const update = (index: number, step: Step) => dispatch({ type: ActionType.UPDATE, payload: { index, step } });

export const skipTutorial = () =>
dispatch({ type: ActionType.CLOSE, payload: { reason: 'skipped' } });

export const tutorial = {
open,
close,
Expand Down
6 changes: 6 additions & 0 deletions packages/main/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export interface Tutorial {
options?: Options;
}

export type TutorialResultReason = 'completed' | 'skipped' | 'closed';

export interface TutorialResult {
reason: TutorialResultReason;
}

export interface ElementStyle {
id: string;
left: number;
Expand Down
25 changes: 25 additions & 0 deletions packages/main/test/content.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,29 @@ describe('Content', () => {
expect(screen.getByText('<strong>Literal HTML</strong>')).toBeInTheDocument();
expect(document.querySelector('strong')).toBeNull();
});

test('skip button resolves the tutorial promise with skipped', async () => {
renderOverlay();
const onClose = jest.fn();

let resultPromise;

act(() => {
resultPromise = tutorial.open({
steps: [
{
title: 'Closable step',
content: 'Closable content',
targetIds: ['first-target'],
},
],
options: { onClose },
});
});

fireEvent.click(screen.getByRole('button', { name: '건너뛰기' }));

await expect(resultPromise).resolves.toEqual({ reason: 'skipped' });
expect(onClose).toHaveBeenCalledTimes(1);
});
});
Loading
Loading