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
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ const App = () => {
title: 'Second step',
content: 'Each step can highlight one or more element ids.',
infoBoxAlignment: 'right',
options: {
infoBoxWidth: '28rem',
infoBoxMargin: 16,
highlightBorderColor: '#f97316',
highlightBorderRadius: 20,
labels: {
done: 'Ship it',
},
},
},
],
options: {
Expand Down Expand Up @@ -121,9 +130,11 @@ console.log(state.currentStep?.title);

`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.

Use `overlayColor`, `highlightBorderColor`, `highlightBorderRadius`, `zIndex`, and `infoBoxWidth` to match the built-in UI to your product without changing the overlay, highlight, or info box structure. When omitted, the existing defaults remain in place.
Global `options` still define the shared tutorial chrome. Use them for shared defaults such as `overlayColor`, `highlightBorderColor`, `highlightBorderRadius`, `infoBoxHeight`, `infoBoxWidth`, `infoBoxMargin`, `labels`, and `zIndex`.

Use `labels` to override the built-in button text. The default labels are `이전`, `다음`, `건너뛰기`, and `완료`.
Use `step.options` when a single step needs different `infoBoxHeight`, `infoBoxWidth`, `infoBoxMargin`, `highlightBorderColor`, `highlightBorderRadius`, or partial `labels`. The fallback order is `step.options` -> global `options` -> built-in defaults. Omitted label keys also follow that order.

`infoBoxAlignment` remains a step field, and `overlayColor` remains global-only so the backdrop stays consistent across the tutorial run.

Keyboard navigation is enabled by default while the overlay is open:

Expand Down
4 changes: 2 additions & 2 deletions packages/document/src/pages/docs/tutorial-overlay.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ Because `tutorial.open()` returns a Promise, you can keep `<TutorialOverlay />`

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.

Visual customization stays global for now. Use `options.overlayColor`, `options.highlightBorderColor`, `options.highlightBorderRadius`, `options.zIndex`, and `options.infoBoxWidth` when you need the built-in overlay UI to better match your product chrome.
Tutorial-level `options` still define the shared defaults for the overlay run. Use `step.options` when a single step needs different `infoBoxHeight`, `infoBoxWidth`, `infoBoxMargin`, `highlightBorderColor`, `highlightBorderRadius`, or partial `labels`.

Use `options.labels` to replace the built-in `이전`, `다음`, `건너뛰기`, and `완료` button text without replacing the UI itself.
The fallback order is `step.options` -> tutorial `options` -> built-in defaults. `overlayColor` remains global-only so the backdrop stays visually consistent across steps.

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
29 changes: 23 additions & 6 deletions packages/document/src/pages/docs/tutorial.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ function App() {
content: 'This message is plain text.',
infoBoxAlignment: 'center',
},
{
targetIds: ['target-id'],
title: 'Focused step styling',
content: 'This step overrides the shared tutorial styling.',
infoBoxAlignment: 'right',
options: {
infoBoxWidth: '28rem',
infoBoxMargin: 16,
highlightBorderColor: '#f97316',
highlightBorderRadius: 20,
labels: {
done: 'Ship it',
},
},
},
],
options: {
highLightPadding: 12,
Expand Down Expand Up @@ -83,6 +98,7 @@ function App() {
- `title`: optional heading shown in the info box.
- `content`: optional plain text body.
- `infoBoxAlignment`: optional `center`, `left`, or `right`.
- `options`: optional per-step overrides for `infoBoxHeight`, `infoBoxWidth`, `infoBoxMargin`, `highlightBorderColor`, `highlightBorderRadius`, and partial `labels`.
- `onPrevStep`: optional callback that runs when leaving the step with `tutorial.prev()`.
- `onNextStep`: optional callback that runs when leaving the step with `tutorial.next()`.

Expand All @@ -105,19 +121,20 @@ console.log(state.currentStep?.title);
## Tutorial options

- `highLightPadding`: expands the highlight frame around the target in pixels. Defaults to `8`.
- `infoBoxHeight`: sets the info box height in pixels.
- `infoBoxWidth`: sets the info box width with a CSS length such as `360` or `'24rem'`. Defaults to `'20rem'`.
- `infoBoxMargin`: controls the vertical gap between the target and the info box.
- `infoBoxHeight`: sets the default info box height in pixels and can be overridden per step.
- `infoBoxWidth`: sets the default info box width with a CSS length such as `360` or `'24rem'`. Defaults to `'20rem'` and can be overridden per step.
- `infoBoxMargin`: controls the default vertical gap between the target and the info box and can be overridden per step.
- `overlayColor`: sets the backdrop color. Defaults to `rgba(0, 0, 0, 0.5)`.
- `highlightBorderColor`: sets the highlight border color. Defaults to `#ff0000`.
- `highlightBorderRadius`: sets the highlight border radius with a CSS length. Defaults to the current padding-based radius.
- `highlightBorderColor`: sets the default highlight border color. Defaults to `#ff0000` and can be overridden per step.
- `highlightBorderRadius`: sets the default highlight border radius with a CSS length. Defaults to the current padding-based radius and can be overridden per step.
- `zIndex`: sets the overlay stack level. Defaults to `9999`.
- `labels`: overrides the built-in `prev`, `next`, `skip`, and `done` button labels.
- `labels`: overrides the built-in `prev`, `next`, `skip`, and `done` button labels by default and can be partially overridden per step.
- `keyboardNavigation`: enables `Escape`, `ArrowLeft`, and `ArrowRight` shortcuts while the overlay is open. Defaults to `true`.
- `closeOnOverlayClick`: closes the tutorial when the backdrop itself is clicked. Defaults to `false`.
- `onClose`: runs when the active tutorial is closed, including replacement by a newer `tutorial.open()` call.

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.
Step customization resolves in this order: `step.options` -> tutorial `options` -> built-in defaults. `overlayColor`, `highLightPadding`, `keyboardNavigation`, `closeOnOverlayClick`, `zIndex`, and `onClose` stay global for the whole run.
When a tutorial opens, focus moves into the labeled dialog UI and returns to the previously active element after close. The current overlay does not trap focus.
`onClose` remains available for side effects, while the Promise returned by `tutorial.open()` is the async completion hook.
6 changes: 3 additions & 3 deletions packages/main/src/components/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useId } from 'react';
import { useTutorialStore } from '../core/store';
import { skipTutorial, tutorial } from '../core/tutorial';
import { styled } from 'goober';
import { DEFAULT_INFO_BOX_WIDTH, INFO_BOX_Z_INDEX_OFFSET, getBaseZIndex, getLabels } from '../core/options';
import { INFO_BOX_Z_INDEX_OFFSET, getBaseZIndex, getInfoBoxWidth, getLabels } from '../core/options';

export const Content = React.forwardRef<HTMLDivElement>((_, ref) => {
const {
Expand All @@ -12,7 +12,7 @@ export const Content = React.forwardRef<HTMLDivElement>((_, ref) => {
const currentStep = steps[index];
const titleId = useId();
const contentId = useId();
const labels = getLabels(options);
const labels = getLabels(options, currentStep);

const handlePrev = () => {
tutorial.prev();
Expand All @@ -34,7 +34,7 @@ export const Content = React.forwardRef<HTMLDivElement>((_, ref) => {
aria-describedby={currentStep.content ? contentId : undefined}
tabIndex={-1}
style={{
width: options?.infoBoxWidth ?? DEFAULT_INFO_BOX_WIDTH,
width: getInfoBoxWidth(options, currentStep),
zIndex: getBaseZIndex(options) + INFO_BOX_Z_INDEX_OFFSET,
}}
>
Expand Down
19 changes: 12 additions & 7 deletions packages/main/src/components/tutorial-overlay.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import type { ElementStyle, Options } from '../core/types';
import type { ElementStyle, Options, Step } from '../core/types';
import { useTutorialStore } from '../core/store';
import { setup, styled } from 'goober';
import { Content } from './content';
Expand All @@ -11,6 +11,10 @@ import {
DEFAULT_Z_INDEX,
HIGHLIGHT_Z_INDEX_OFFSET,
getBaseZIndex,
getHighlightBorderColor,
getHighlightBorderRadius,
getInfoBoxHeight,
getInfoBoxMargin,
} from '../core/options';

setup(React.createElement);
Expand Down Expand Up @@ -134,7 +138,7 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
});

if (infoBoxAnchor) {
calculateInfoBoxPosition(infoBoxAnchor, stepConfig.infoBoxAlignment);
calculateInfoBoxPosition(infoBoxAnchor, stepConfig);
}

if (currentElements.current.length === 0 || !alreadyCalculated) {
Expand All @@ -144,9 +148,10 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
setRectStyles(positions);
}

function calculateInfoBoxPosition(position: ElementStyle, alignment?: 'center' | 'left' | 'right') {
const boxHeight = options?.infoBoxHeight ?? 200;
const margin = options?.infoBoxMargin ?? 30;
function calculateInfoBoxPosition(position: ElementStyle, step: Step) {
const alignment = step.infoBoxAlignment;
const boxHeight = getInfoBoxHeight(options, step);
const margin = getInfoBoxMargin(options, step);
const minLeft = window.scrollX + MIN_VIEWPORT_OFFSET;
const maxLeft = window.scrollX + window.innerWidth - MIN_VIEWPORT_OFFSET;
const minTop = window.scrollY + MIN_VIEWPORT_OFFSET;
Expand Down Expand Up @@ -308,8 +313,8 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
key={style.id}
style={{
...style,
borderColor: options?.highlightBorderColor ?? DEFAULT_HIGHLIGHT_BORDER_COLOR,
borderRadius: options?.highlightBorderRadius ?? style.borderRadius,
borderColor: getHighlightBorderColor(options, steps[index]),
borderRadius: getHighlightBorderRadius(options, steps[index], style.borderRadius),
zIndex: baseZIndex + HIGHLIGHT_Z_INDEX_OFFSET,
}}
/>
Expand Down
31 changes: 29 additions & 2 deletions packages/main/src/core/options.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { Labels, Options } from './types';
import type { Labels, Options, Step, StyleValue } from './types';

export const DEFAULT_HIGHLIGHT_PADDING = 8;
export const DEFAULT_OVERLAY_COLOR = 'rgba(0, 0, 0, 0.5)';
export const DEFAULT_HIGHLIGHT_BORDER_COLOR = '#ff0000';
export const DEFAULT_INFO_BOX_HEIGHT = 200;
export const DEFAULT_INFO_BOX_WIDTH = '20rem';
export const DEFAULT_INFO_BOX_MARGIN = 30;
export const DEFAULT_Z_INDEX = 9999;
export const HIGHLIGHT_Z_INDEX_OFFSET = 1;
export const INFO_BOX_Z_INDEX_OFFSET = 2;
Expand All @@ -15,13 +17,38 @@ export const DEFAULT_LABELS: Required<Labels> = {
done: '완료',
};

export function getLabels(options?: Options): Required<Labels> {
export function getLabels(options?: Options, step?: Step): Required<Labels> {
return {
...DEFAULT_LABELS,
...options?.labels,
...step?.options?.labels,
};
}

export function getBaseZIndex(options?: Options): number {
return options?.zIndex ?? DEFAULT_Z_INDEX;
}

export function getInfoBoxHeight(options?: Options, step?: Step): number {
return step?.options?.infoBoxHeight ?? options?.infoBoxHeight ?? DEFAULT_INFO_BOX_HEIGHT;
}

export function getInfoBoxWidth(options?: Options, step?: Step): StyleValue {
return step?.options?.infoBoxWidth ?? options?.infoBoxWidth ?? DEFAULT_INFO_BOX_WIDTH;
}

export function getInfoBoxMargin(options?: Options, step?: Step): number {
return step?.options?.infoBoxMargin ?? options?.infoBoxMargin ?? DEFAULT_INFO_BOX_MARGIN;
}

export function getHighlightBorderColor(options?: Options, step?: Step): string {
return step?.options?.highlightBorderColor ?? options?.highlightBorderColor ?? DEFAULT_HIGHLIGHT_BORDER_COLOR;
}

export function getHighlightBorderRadius(
options?: Options,
step?: Step,
fallbackBorderRadius?: StyleValue
): StyleValue | undefined {
return step?.options?.highlightBorderRadius ?? options?.highlightBorderRadius ?? fallbackBorderRadius;
}
6 changes: 6 additions & 0 deletions packages/main/src/core/tutorial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ const getState = (): TutorialProgressState => {
? {
...currentStep,
targetIds: [...currentStep.targetIds],
options: currentStep.options
? {
...currentStep.options,
labels: currentStep.options.labels ? { ...currentStep.options.labels } : undefined,
}
: undefined,
}
: null,
};
Expand Down
27 changes: 19 additions & 8 deletions packages/main/src/core/types.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
export type StyleValue = number | string;
export type InfoBoxAlignment = 'center' | 'left' | 'right';

export interface Labels {
prev?: string;
next?: string;
skip?: string;
done?: string;
}

export interface StepOptions {
infoBoxHeight?: number;
infoBoxWidth?: StyleValue;
infoBoxMargin?: number;
highlightBorderColor?: string;
highlightBorderRadius?: StyleValue;
labels?: Labels;
}

export interface Step {
targetIds: string[];
content?: string;
title?: string;
infoBoxAlignment?: 'center' | 'left' | 'right';
infoBoxAlignment?: InfoBoxAlignment;
options?: StepOptions;
onPrevStep?: () => void;
onNextStep?: () => void;
}

export interface Labels {
prev?: string;
next?: string;
skip?: string;
done?: string;
}

export interface Options {
highLightPadding?: number;
infoBoxHeight?: number;
Expand Down
11 changes: 11 additions & 0 deletions packages/main/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,16 @@ import { tutorial } from './core/tutorial';

export { TutorialOverlay } from './components/tutorial-overlay';
export { tutorial };
export type {
InfoBoxAlignment,
Labels,
Options,
Step,
StepOptions,
Tutorial,
TutorialProgressState,
TutorialResult,
TutorialResultReason,
} from './core/types';

export default tutorial;
44 changes: 44 additions & 0 deletions packages/main/test/content.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,50 @@ function renderOverlay() {
}

describe('Content', () => {
test('prefers step labels over global labels and falls back to global or built-in labels', () => {
renderOverlay();

act(() => {
tutorial.open({
steps: [
{
title: 'Step 1',
content: 'Step 1 content',
targetIds: ['first-target'],
options: {
labels: {
skip: 'Leave',
},
},
},
{
title: 'Step 2',
content: 'Step 2 content',
targetIds: ['second-target'],
options: {
labels: {
done: 'Ship it',
},
},
},
],
options: {
labels: {
next: 'Continue',
},
},
});
});

expect(screen.getByRole('button', { name: 'Leave' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: 'Continue' }));

expect(screen.getByRole('button', { name: '이전' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Ship it' })).toBeInTheDocument();
});

test('reads built-in button labels from options.labels', () => {
renderOverlay();

Expand Down
Loading
Loading