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
9 changes: 9 additions & 0 deletions .changeset/neat-pears-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'react-tutorial-overlay': minor
---

Add explicit progress control APIs for active tutorials.

- Added `startAt` to `tutorial.open()` so callers can choose the initial step without mutating internal state.
- Added `tutorial.goTo(index)` for imperative step navigation with clamped index handling.
- Added `tutorial.getState()` to expose a small read-only progress snapshot for external controls.
- Documented the new progress control contract and added regression tests for Promise API compatibility.
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,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()`
- 🕹 imperative controls via `tutorial.open()`, `next()`, `prev()`, `goTo()`, `getState()`, and `close()`
- ⏳ await tutorial completion with a small Promise result payload
- 📦 minimal setup with a single `<TutorialOverlay />` mount

Expand Down Expand Up @@ -94,6 +94,28 @@ const App = () => {
- `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.

To start from a specific step, pass `startAt` to `tutorial.open()`:

```jsx
await tutorial.open({
steps,
startAt: 1,
});
```

Use `tutorial.goTo(index)` to move the active tutorial to a specific step. The index is clamped to the available range, and calling it while the overlay is closed is a no-op. `goTo()` only changes the active step. It does not resolve the pending Promise and does not trigger `onPrevStep` or `onNextStep`.

Use `tutorial.getState()` when you need a read-only snapshot for external controls:

```jsx
const state = tutorial.getState();

console.log(state.open);
console.log(state.index);
console.log(state.stepCount);
console.log(state.currentStep?.title);
```

`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 @@ -118,7 +140,7 @@ For accessibility, the info box is exposed as a labeled `dialog`. When the tutor

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

## Documentation

Expand Down
9 changes: 9 additions & 0 deletions docs/plans/2026-03-24-project-restart.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- 완료: `codex/add-highlight-padding-and-overlay-click-behavior` 병합 완료
- 완료: `codex/add-promise-api` 병합 완료
- 완료: `codex/improve-accessibility` 병합 완료
- 완료: `codex/add-progress-control-api` 병합 완료
- 반영된 내용:
- `packages/main/test/setup.ts` 추가
- `packages/main/jest.config.js` 정상화
Expand Down Expand Up @@ -48,6 +49,12 @@
- 마지막 step 완료, built-in 건너뛰기, 외부 close 경로별 resolve reason 구분 추가
- 새 tutorial open 시 이전 pending promise를 `closed`로 정리하도록 보정
- Promise API와 `onClose`의 역할을 분리하고 기존 callback 계약 유지
- `tutorial.open()`에 `startAt` 추가로 시작 step을 명시적으로 제어 가능
- `tutorial.goTo(index)` 추가로 active tutorial의 step index를 외부에서 이동 가능
- `tutorial.getState()` 추가로 open 여부, 현재 index, step 수, current step snapshot 조회 가능
- `startAt` / `goTo(index)`의 잘못된 index는 step 범위로 clamp, closed 상태의 `goTo()`는 no-op으로 정리
- `goTo()`가 Promise resolve 및 `onPrevStep` / `onNextStep` callback을 건드리지 않도록 역할 분리
- progress control API 관련 main package 테스트 및 README/docs 예제 추가
- README 및 docs에 async / await 사용 예제 추가
- info box를 labeled `dialog`로 노출하고 title/content를 `aria-labelledby` / `aria-describedby`로 연결
- overlay open 시 info box의 첫 built-in control로 focus 이동 추가
Expand Down Expand Up @@ -83,6 +90,8 @@

후속 접근성 안정화 작업으로 권장했던 `codex/improve-accessibility`도 완료 및 병합됐다. 현재 overlay는 dialog semantics와 open/close focus lifecycle까지는 안정화됐고, focus trap과 background inert는 다음 접근성 확장 후보로 남겨둔다.

첫 progress control 확장 후보였던 `codex/add-progress-control-api`도 완료 및 병합됐다. 이제 외부 제어는 `startAt`, `goTo(index)`, `getState()`까지 포함하는 수준으로 정리됐고, 다음 확장 후보는 richer state subscription이나 callback surface가 필요한지 여부를 별도 계획 문서에서 다시 판단하면 된다.

이 문서는 현재까지 반영된 후속 기능 이력을 남기는 참고 문서로 유지한다. 다음 신규 기능 후보는 별도 계획 문서에서 다시 우선순위를 잡는 편이 맞다.

## Common Setup For Every Worktree
Expand Down
2 changes: 2 additions & 0 deletions packages/document/src/pages/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,6 @@ Mount `<TutorialOverlay />` once in your app. Then call `tutorial.open({ steps,

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

Use `startAt` to open on a specific step, `tutorial.goTo(index)` to move within an active tutorial, and `tutorial.getState()` to read `{ open, index, stepCount, currentStep }` for external progress UI.

`content` accepts a string. HTML inside that string is shown as text rather than injected into the page.
20 changes: 19 additions & 1 deletion packages/document/src/pages/docs/tutorial.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@ function App() {

## Available methods

- `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.open({ steps, options, startAt })`: opens a tutorial and starts at `startAt` when provided. `startAt` is clamped to the available step range. 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.goTo(index)`: moves the active tutorial to a specific step. The index is clamped to the available range, and calling it while closed is a no-op.
- `tutorial.getState()`: returns `{ open, index, stepCount, currentStep }` for external progress controls.
- `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.

Expand All @@ -83,6 +85,22 @@ function App() {
- `onPrevStep`: optional callback that runs when leaving the step with `tutorial.prev()`.
- `onNextStep`: optional callback that runs when leaving the step with `tutorial.next()`.

## External progress control

```jsx
await tutorial.open({
steps,
startAt: 1,
});

tutorial.goTo(0);

const state = tutorial.getState();
console.log(state.currentStep?.title);
```

`tutorial.goTo()` only updates the active index. It does not resolve the Promise returned by `tutorial.open()` and does not invoke `onPrevStep` or `onNextStep`.

## Tutorial options

- `highLightPadding`: expands the highlight frame around the target in pixels. Defaults to `8`.
Expand Down
24 changes: 23 additions & 1 deletion packages/main/src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export enum ActionType {
CLOSE,
NEXT,
PREV,
GOTO,
UPDATE,
}

Expand All @@ -14,6 +15,7 @@ type Action =
| { type: ActionType.CLOSE; payload: { reason: TutorialResultReason } }
| { type: ActionType.NEXT }
| { type: ActionType.PREV }
| { type: ActionType.GOTO; payload: { index: number } }
| { type: ActionType.UPDATE; payload: { index: number; step: Step } };

export interface State {
Expand All @@ -33,6 +35,14 @@ const initialState: State = {

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

const clampIndex = (index: number, stepCount: number): number => {
if (stepCount <= 0 || !Number.isFinite(index)) {
return 0;
}

return Math.min(Math.max(Math.trunc(index), 0), stepCount - 1);
};

function settlePendingTutorial(result: TutorialResult) {
if (!pendingTutorialResolver) {
return;
Expand All @@ -46,7 +56,10 @@ function settlePendingTutorial(result: TutorialResult) {
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case ActionType.OPEN:
return action.payload;
return {
...action.payload,
index: clampIndex(action.payload.index, action.payload.tutorial.steps.length),
};
case ActionType.CLOSE:
settlePendingTutorial({ reason: action.payload.reason });
state.tutorial.options?.onClose?.();
Expand All @@ -70,6 +83,15 @@ const reducer = (state: State, action: Action): State => {
};
}
return state;
case ActionType.GOTO:
if (!state.open || state.tutorial.steps.length === 0) {
return state;
}

return {
...state,
index: clampIndex(action.payload.index, state.tutorial.steps.length),
};
case ActionType.UPDATE:
return {
...state,
Expand Down
29 changes: 25 additions & 4 deletions packages/main/src/core/tutorial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,49 @@ import {
State,
createPendingTutorialResult,
dispatch,
getState,
getState as getStoreState,
hasPendingTutorialResult,
} from './store';
import type { Step, Tutorial, TutorialResult } from './types';
import type { Step, Tutorial, TutorialProgressState, TutorialResult } from './types';

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

const resultPromise = createPendingTutorialResult();
const requestedIndex = otherState?.index ?? tutorial.startAt ?? 0;

dispatch({
type: ActionType.OPEN,
payload: { tutorial, index: otherState?.index ?? 0, open: otherState?.open ?? true },
payload: { tutorial, index: requestedIndex, open: otherState?.open ?? true },
});

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

const getState = (): TutorialProgressState => {
const state = getStoreState();
const currentStep = state.open ? state.tutorial.steps[state.index] ?? null : null;

return {
open: state.open,
index: state.index,
stepCount: state.tutorial.steps.length,
currentStep: currentStep
? {
...currentStep,
targetIds: [...currentStep.targetIds],
}
: null,
};
};

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

Expand All @@ -35,5 +54,7 @@ export const tutorial = {
close,
next,
prev,
goTo,
getState,
update,
};
8 changes: 8 additions & 0 deletions packages/main/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface Options {
export interface Tutorial {
steps: Step[];
options?: Options;
startAt?: number;
}

export type TutorialResultReason = 'completed' | 'skipped' | 'closed';
Expand All @@ -42,6 +43,13 @@ export interface TutorialResult {
reason: TutorialResultReason;
}

export interface TutorialProgressState {
open: boolean;
index: number;
stepCount: number;
currentStep: Step | null;
}

export interface ElementStyle {
id: string;
left: number;
Expand Down
33 changes: 33 additions & 0 deletions packages/main/test/store.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { act, render, screen } from '@testing-library/react';
import { useTutorialStore } from '../src/core/store';
import { tutorial } from '../src/core/tutorial';

const progressTutorial = tutorial as typeof tutorial & {
goTo: (index: number) => void;
};

function StateProbe() {
const {
index,
Expand Down Expand Up @@ -63,4 +67,33 @@ describe('store step callbacks', () => {
expect(onPrevStep).not.toHaveBeenCalled();
expect(screen.getByTestId('title')).toHaveTextContent('Step 1');
});

test('tutorial.goTo does not invoke step transition callbacks', () => {
const onNextStep = jest.fn();
const onPrevStep = jest.fn();

render(<StateProbe />);

act(() => {
tutorial.open({
steps: [
{ title: 'Step 1', targetIds: ['first-target'], onNextStep },
{ title: 'Step 2', targetIds: ['second-target'], onPrevStep },
],
options: {},
});
});

act(() => {
progressTutorial.goTo(1);
});

act(() => {
progressTutorial.goTo(0);
});

expect(onNextStep).not.toHaveBeenCalled();
expect(onPrevStep).not.toHaveBeenCalled();
expect(screen.getByTestId('title')).toHaveTextContent('Step 1');
});
});
Loading
Loading