diff --git a/.changeset/neat-pears-wait.md b/.changeset/neat-pears-wait.md
new file mode 100644
index 0000000..d58d5ea
--- /dev/null
+++ b/.changeset/neat-pears-wait.md
@@ -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.
diff --git a/README.md b/README.md
index ee00dc2..3ef0c59 100644
--- a/README.md
+++ b/README.md
@@ -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 `` mount
@@ -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.
@@ -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 `` once near the root of your app, then trigger `tutorial.open({ steps, options })` from any event handler or effect.
+Mount `` once near the root of your app, then trigger `tutorial.open({ steps, options, startAt })` from any event handler or effect.
## Documentation
diff --git a/docs/plans/2026-03-24-project-restart.md b/docs/plans/2026-03-24-project-restart.md
index 8efdc66..888f90e 100644
--- a/docs/plans/2026-03-24-project-restart.md
+++ b/docs/plans/2026-03-24-project-restart.md
@@ -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` ์ ์ํ
@@ -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 ์ด๋ ์ถ๊ฐ
@@ -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
diff --git a/packages/document/src/pages/docs/index.mdx b/packages/document/src/pages/docs/index.mdx
index 47ecd7a..64980e1 100644
--- a/packages/document/src/pages/docs/index.mdx
+++ b/packages/document/src/pages/docs/index.mdx
@@ -60,4 +60,6 @@ Mount `` 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.
diff --git a/packages/document/src/pages/docs/tutorial.mdx b/packages/document/src/pages/docs/tutorial.mdx
index 3ea025e..1db3de4 100644
--- a/packages/document/src/pages/docs/tutorial.mdx
+++ b/packages/document/src/pages/docs/tutorial.mdx
@@ -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.
@@ -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`.
diff --git a/packages/main/src/core/store.ts b/packages/main/src/core/store.ts
index 4b91a56..7966c67 100644
--- a/packages/main/src/core/store.ts
+++ b/packages/main/src/core/store.ts
@@ -6,6 +6,7 @@ export enum ActionType {
CLOSE,
NEXT,
PREV,
+ GOTO,
UPDATE,
}
@@ -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 {
@@ -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;
@@ -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?.();
@@ -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,
diff --git a/packages/main/src/core/tutorial.ts b/packages/main/src/core/tutorial.ts
index f489567..7dbde02 100644
--- a/packages/main/src/core/tutorial.ts
+++ b/packages/main/src/core/tutorial.ts
@@ -3,21 +3,22 @@ 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): Promise => {
- 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;
@@ -25,8 +26,26 @@ const open = (tutorial: Tutorial, otherState?: Omit): Promise
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' } });
@@ -35,5 +54,7 @@ export const tutorial = {
close,
next,
prev,
+ goTo,
+ getState,
update,
};
diff --git a/packages/main/src/core/types.ts b/packages/main/src/core/types.ts
index e3198d8..f9d5792 100644
--- a/packages/main/src/core/types.ts
+++ b/packages/main/src/core/types.ts
@@ -34,6 +34,7 @@ export interface Options {
export interface Tutorial {
steps: Step[];
options?: Options;
+ startAt?: number;
}
export type TutorialResultReason = 'completed' | 'skipped' | 'closed';
@@ -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;
diff --git a/packages/main/test/store.test.tsx b/packages/main/test/store.test.tsx
index b2f036b..1be67e0 100644
--- a/packages/main/test/store.test.tsx
+++ b/packages/main/test/store.test.tsx
@@ -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,
@@ -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();
+
+ 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');
+ });
});
diff --git a/packages/main/test/tutorial.test.tsx b/packages/main/test/tutorial.test.tsx
index 1cc9dcb..1af0959 100644
--- a/packages/main/test/tutorial.test.tsx
+++ b/packages/main/test/tutorial.test.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { act, render, screen } from '@testing-library/react';
import { useTutorialStore } from '../src/core/store';
import { tutorial } from '../src/core/tutorial';
-import type { Tutorial } from '../src/core/types';
+import type { Step, Tutorial } from '../src/core/types';
const twoStepTutorial: Tutorial = {
steps: [
@@ -12,6 +12,18 @@ const twoStepTutorial: Tutorial = {
options: {},
};
+type TutorialController = typeof tutorial & {
+ goTo: (index: number) => void;
+ getState: () => {
+ open: boolean;
+ index: number;
+ stepCount: number;
+ currentStep: Step | null;
+ };
+};
+
+const progressTutorial = tutorial as TutorialController;
+
function StateProbe() {
const {
index,
@@ -52,6 +64,21 @@ describe('tutorial core API', () => {
expect(screen.getByTestId('title')).toHaveTextContent('Step 2');
});
+ test('tutorial.open supports startAt and clamps it to the available step range', () => {
+ render();
+
+ act(() => {
+ tutorial.open({
+ ...twoStepTutorial,
+ startAt: 99,
+ } as Tutorial & { startAt: number });
+ });
+
+ expect(screen.getByTestId('open')).toHaveTextContent('true');
+ expect(screen.getByTestId('index')).toHaveTextContent('1');
+ expect(screen.getByTestId('title')).toHaveTextContent('Step 2');
+ });
+
test('tutorial.next closes the tutorial on the last step', () => {
render();
@@ -92,6 +119,72 @@ describe('tutorial core API', () => {
expect(screen.getByTestId('title')).toHaveTextContent('none');
});
+ test('tutorial.goTo moves to a specific step and clamps invalid indexes', () => {
+ render();
+
+ act(() => {
+ tutorial.open(twoStepTutorial);
+ });
+
+ act(() => {
+ progressTutorial.goTo(1);
+ });
+
+ expect(screen.getByTestId('index')).toHaveTextContent('1');
+ expect(screen.getByTestId('title')).toHaveTextContent('Step 2');
+
+ act(() => {
+ progressTutorial.goTo(-3);
+ });
+
+ expect(screen.getByTestId('index')).toHaveTextContent('0');
+ expect(screen.getByTestId('title')).toHaveTextContent('Step 1');
+
+ act(() => {
+ progressTutorial.goTo(10);
+ });
+
+ expect(screen.getByTestId('index')).toHaveTextContent('1');
+ expect(screen.getByTestId('title')).toHaveTextContent('Step 2');
+ });
+
+ test('tutorial.goTo is a no-op while the tutorial is closed', () => {
+ render();
+
+ act(() => {
+ progressTutorial.goTo(1);
+ });
+
+ expect(screen.getByTestId('open')).toHaveTextContent('false');
+ expect(screen.getByTestId('index')).toHaveTextContent('0');
+ expect(screen.getByTestId('title')).toHaveTextContent('none');
+ });
+
+ test('tutorial.getState returns a public snapshot for external progress control', () => {
+ render();
+
+ expect(progressTutorial.getState()).toEqual({
+ open: false,
+ index: 0,
+ stepCount: 0,
+ currentStep: null,
+ });
+
+ act(() => {
+ tutorial.open({
+ ...twoStepTutorial,
+ startAt: 1,
+ } as Tutorial & { startAt: number });
+ });
+
+ expect(progressTutorial.getState()).toEqual({
+ open: true,
+ index: 1,
+ stepCount: 2,
+ currentStep: { title: 'Step 2', targetIds: ['second-target'] },
+ });
+ });
+
test('tutorial.open resolves with completed when the last step finishes', async () => {
render();
const onClose = jest.fn();
@@ -113,6 +206,26 @@ describe('tutorial core API', () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
+ test('tutorial.goTo only changes the active step and keeps the open promise contract intact', async () => {
+ render();
+
+ let resultPromise;
+
+ act(() => {
+ resultPromise = tutorial.open(twoStepTutorial);
+ });
+
+ act(() => {
+ progressTutorial.goTo(1);
+ });
+
+ act(() => {
+ tutorial.next();
+ });
+
+ await expect(resultPromise).resolves.toEqual({ reason: 'completed' });
+ });
+
test('tutorial.close resolves the active tutorial with closed', async () => {
render();
const onClose = jest.fn();