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();