Skip to content

Commit a391274

Browse files
authored
Merge pull request #17 from sjsjsj1246/codex/add-step-level-customization
[-]: step 단위 커스터마이징 override 추가
2 parents ffa1cda + 91900e9 commit a391274

11 files changed

Lines changed: 212 additions & 30 deletions

File tree

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ const App = () => {
4747
title: 'Second step',
4848
content: 'Each step can highlight one or more element ids.',
4949
infoBoxAlignment: 'right',
50+
options: {
51+
infoBoxWidth: '28rem',
52+
infoBoxMargin: 16,
53+
highlightBorderColor: '#f97316',
54+
highlightBorderRadius: 20,
55+
labels: {
56+
done: 'Ship it',
57+
},
58+
},
5059
},
5160
],
5261
options: {
@@ -121,9 +130,11 @@ console.log(state.currentStep?.title);
121130
122131
`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.
123132
124-
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.
133+
Global `options` still define the shared tutorial chrome. Use them for shared defaults such as `overlayColor`, `highlightBorderColor`, `highlightBorderRadius`, `infoBoxHeight`, `infoBoxWidth`, `infoBoxMargin`, `labels`, and `zIndex`.
125134
126-
Use `labels` to override the built-in button text. The default labels are `이전`, `다음`, `건너뛰기`, and `완료`.
135+
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.
136+
137+
`infoBoxAlignment` remains a step field, and `overlayColor` remains global-only so the backdrop stays consistent across the tutorial run.
127138
128139
Keyboard navigation is enabled by default while the overlay is open:
129140

packages/document/src/pages/docs/tutorial-overlay.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ Because `tutorial.open()` returns a Promise, you can keep `<TutorialOverlay />`
2828

2929
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.
3030

31-
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.
31+
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`.
3232

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

3535
By default, the mounted overlay listens for `Escape`, `ArrowLeft`, and `ArrowRight` while it is open. You can disable that with `options.keyboardNavigation = false`.
3636

packages/document/src/pages/docs/tutorial.mdx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ function App() {
2525
content: 'This message is plain text.',
2626
infoBoxAlignment: 'center',
2727
},
28+
{
29+
targetIds: ['target-id'],
30+
title: 'Focused step styling',
31+
content: 'This step overrides the shared tutorial styling.',
32+
infoBoxAlignment: 'right',
33+
options: {
34+
infoBoxWidth: '28rem',
35+
infoBoxMargin: 16,
36+
highlightBorderColor: '#f97316',
37+
highlightBorderRadius: 20,
38+
labels: {
39+
done: 'Ship it',
40+
},
41+
},
42+
},
2843
],
2944
options: {
3045
highLightPadding: 12,
@@ -83,6 +98,7 @@ function App() {
8398
- `title`: optional heading shown in the info box.
8499
- `content`: optional plain text body.
85100
- `infoBoxAlignment`: optional `center`, `left`, or `right`.
101+
- `options`: optional per-step overrides for `infoBoxHeight`, `infoBoxWidth`, `infoBoxMargin`, `highlightBorderColor`, `highlightBorderRadius`, and partial `labels`.
86102
- `onPrevStep`: optional callback that runs when leaving the step with `tutorial.prev()`.
87103
- `onNextStep`: optional callback that runs when leaving the step with `tutorial.next()`.
88104

@@ -105,19 +121,20 @@ console.log(state.currentStep?.title);
105121
## Tutorial options
106122
107123
- `highLightPadding`: expands the highlight frame around the target in pixels. Defaults to `8`.
108-
- `infoBoxHeight`: sets the info box height in pixels.
109-
- `infoBoxWidth`: sets the info box width with a CSS length such as `360` or `'24rem'`. Defaults to `'20rem'`.
110-
- `infoBoxMargin`: controls the vertical gap between the target and the info box.
124+
- `infoBoxHeight`: sets the default info box height in pixels and can be overridden per step.
125+
- `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.
126+
- `infoBoxMargin`: controls the default vertical gap between the target and the info box and can be overridden per step.
111127
- `overlayColor`: sets the backdrop color. Defaults to `rgba(0, 0, 0, 0.5)`.
112-
- `highlightBorderColor`: sets the highlight border color. Defaults to `#ff0000`.
113-
- `highlightBorderRadius`: sets the highlight border radius with a CSS length. Defaults to the current padding-based radius.
128+
- `highlightBorderColor`: sets the default highlight border color. Defaults to `#ff0000` and can be overridden per step.
129+
- `highlightBorderRadius`: sets the default highlight border radius with a CSS length. Defaults to the current padding-based radius and can be overridden per step.
114130
- `zIndex`: sets the overlay stack level. Defaults to `9999`.
115-
- `labels`: overrides the built-in `prev`, `next`, `skip`, and `done` button labels.
131+
- `labels`: overrides the built-in `prev`, `next`, `skip`, and `done` button labels by default and can be partially overridden per step.
116132
- `keyboardNavigation`: enables `Escape`, `ArrowLeft`, and `ArrowRight` shortcuts while the overlay is open. Defaults to `true`.
117133
- `closeOnOverlayClick`: closes the tutorial when the backdrop itself is clicked. Defaults to `false`.
118134
- `onClose`: runs when the active tutorial is closed, including replacement by a newer `tutorial.open()` call.
119135
120136
Keyboard shortcuts are ignored while an `input`, `textarea`, `select`, or `contenteditable` element has focus.
121137
The info box automatically repositions itself to stay within the viewport when the target is close to an edge.
138+
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.
122139
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.
123140
`onClose` remains available for side effects, while the Promise returned by `tutorial.open()` is the async completion hook.

packages/main/src/components/content.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useId } from 'react';
22
import { useTutorialStore } from '../core/store';
33
import { skipTutorial, tutorial } from '../core/tutorial';
44
import { styled } from 'goober';
5-
import { DEFAULT_INFO_BOX_WIDTH, INFO_BOX_Z_INDEX_OFFSET, getBaseZIndex, getLabels } from '../core/options';
5+
import { INFO_BOX_Z_INDEX_OFFSET, getBaseZIndex, getInfoBoxWidth, getLabels } from '../core/options';
66

77
export const Content = React.forwardRef<HTMLDivElement>((_, ref) => {
88
const {
@@ -12,7 +12,7 @@ export const Content = React.forwardRef<HTMLDivElement>((_, ref) => {
1212
const currentStep = steps[index];
1313
const titleId = useId();
1414
const contentId = useId();
15-
const labels = getLabels(options);
15+
const labels = getLabels(options, currentStep);
1616

1717
const handlePrev = () => {
1818
tutorial.prev();
@@ -34,7 +34,7 @@ export const Content = React.forwardRef<HTMLDivElement>((_, ref) => {
3434
aria-describedby={currentStep.content ? contentId : undefined}
3535
tabIndex={-1}
3636
style={{
37-
width: options?.infoBoxWidth ?? DEFAULT_INFO_BOX_WIDTH,
37+
width: getInfoBoxWidth(options, currentStep),
3838
zIndex: getBaseZIndex(options) + INFO_BOX_Z_INDEX_OFFSET,
3939
}}
4040
>

packages/main/src/components/tutorial-overlay.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useEffect, useRef, useState } from 'react';
2-
import type { ElementStyle, Options } from '../core/types';
2+
import type { ElementStyle, Options, Step } from '../core/types';
33
import { useTutorialStore } from '../core/store';
44
import { setup, styled } from 'goober';
55
import { Content } from './content';
@@ -11,6 +11,10 @@ import {
1111
DEFAULT_Z_INDEX,
1212
HIGHLIGHT_Z_INDEX_OFFSET,
1313
getBaseZIndex,
14+
getHighlightBorderColor,
15+
getHighlightBorderRadius,
16+
getInfoBoxHeight,
17+
getInfoBoxMargin,
1418
} from '../core/options';
1519

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

136140
if (infoBoxAnchor) {
137-
calculateInfoBoxPosition(infoBoxAnchor, stepConfig.infoBoxAlignment);
141+
calculateInfoBoxPosition(infoBoxAnchor, stepConfig);
138142
}
139143

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

147-
function calculateInfoBoxPosition(position: ElementStyle, alignment?: 'center' | 'left' | 'right') {
148-
const boxHeight = options?.infoBoxHeight ?? 200;
149-
const margin = options?.infoBoxMargin ?? 30;
151+
function calculateInfoBoxPosition(position: ElementStyle, step: Step) {
152+
const alignment = step.infoBoxAlignment;
153+
const boxHeight = getInfoBoxHeight(options, step);
154+
const margin = getInfoBoxMargin(options, step);
150155
const minLeft = window.scrollX + MIN_VIEWPORT_OFFSET;
151156
const maxLeft = window.scrollX + window.innerWidth - MIN_VIEWPORT_OFFSET;
152157
const minTop = window.scrollY + MIN_VIEWPORT_OFFSET;
@@ -308,8 +313,8 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
308313
key={style.id}
309314
style={{
310315
...style,
311-
borderColor: options?.highlightBorderColor ?? DEFAULT_HIGHLIGHT_BORDER_COLOR,
312-
borderRadius: options?.highlightBorderRadius ?? style.borderRadius,
316+
borderColor: getHighlightBorderColor(options, steps[index]),
317+
borderRadius: getHighlightBorderRadius(options, steps[index], style.borderRadius),
313318
zIndex: baseZIndex + HIGHLIGHT_Z_INDEX_OFFSET,
314319
}}
315320
/>

packages/main/src/core/options.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import type { Labels, Options } from './types';
1+
import type { Labels, Options, Step, StyleValue } from './types';
22

33
export const DEFAULT_HIGHLIGHT_PADDING = 8;
44
export const DEFAULT_OVERLAY_COLOR = 'rgba(0, 0, 0, 0.5)';
55
export const DEFAULT_HIGHLIGHT_BORDER_COLOR = '#ff0000';
6+
export const DEFAULT_INFO_BOX_HEIGHT = 200;
67
export const DEFAULT_INFO_BOX_WIDTH = '20rem';
8+
export const DEFAULT_INFO_BOX_MARGIN = 30;
79
export const DEFAULT_Z_INDEX = 9999;
810
export const HIGHLIGHT_Z_INDEX_OFFSET = 1;
911
export const INFO_BOX_Z_INDEX_OFFSET = 2;
@@ -15,13 +17,38 @@ export const DEFAULT_LABELS: Required<Labels> = {
1517
done: '완료',
1618
};
1719

18-
export function getLabels(options?: Options): Required<Labels> {
20+
export function getLabels(options?: Options, step?: Step): Required<Labels> {
1921
return {
2022
...DEFAULT_LABELS,
2123
...options?.labels,
24+
...step?.options?.labels,
2225
};
2326
}
2427

2528
export function getBaseZIndex(options?: Options): number {
2629
return options?.zIndex ?? DEFAULT_Z_INDEX;
2730
}
31+
32+
export function getInfoBoxHeight(options?: Options, step?: Step): number {
33+
return step?.options?.infoBoxHeight ?? options?.infoBoxHeight ?? DEFAULT_INFO_BOX_HEIGHT;
34+
}
35+
36+
export function getInfoBoxWidth(options?: Options, step?: Step): StyleValue {
37+
return step?.options?.infoBoxWidth ?? options?.infoBoxWidth ?? DEFAULT_INFO_BOX_WIDTH;
38+
}
39+
40+
export function getInfoBoxMargin(options?: Options, step?: Step): number {
41+
return step?.options?.infoBoxMargin ?? options?.infoBoxMargin ?? DEFAULT_INFO_BOX_MARGIN;
42+
}
43+
44+
export function getHighlightBorderColor(options?: Options, step?: Step): string {
45+
return step?.options?.highlightBorderColor ?? options?.highlightBorderColor ?? DEFAULT_HIGHLIGHT_BORDER_COLOR;
46+
}
47+
48+
export function getHighlightBorderRadius(
49+
options?: Options,
50+
step?: Step,
51+
fallbackBorderRadius?: StyleValue
52+
): StyleValue | undefined {
53+
return step?.options?.highlightBorderRadius ?? options?.highlightBorderRadius ?? fallbackBorderRadius;
54+
}

packages/main/src/core/tutorial.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ const getState = (): TutorialProgressState => {
4141
? {
4242
...currentStep,
4343
targetIds: [...currentStep.targetIds],
44+
options: currentStep.options
45+
? {
46+
...currentStep.options,
47+
labels: currentStep.options.labels ? { ...currentStep.options.labels } : undefined,
48+
}
49+
: undefined,
4450
}
4551
: null,
4652
};

packages/main/src/core/types.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,32 @@
11
export type StyleValue = number | string;
2+
export type InfoBoxAlignment = 'center' | 'left' | 'right';
3+
4+
export interface Labels {
5+
prev?: string;
6+
next?: string;
7+
skip?: string;
8+
done?: string;
9+
}
10+
11+
export interface StepOptions {
12+
infoBoxHeight?: number;
13+
infoBoxWidth?: StyleValue;
14+
infoBoxMargin?: number;
15+
highlightBorderColor?: string;
16+
highlightBorderRadius?: StyleValue;
17+
labels?: Labels;
18+
}
219

320
export interface Step {
421
targetIds: string[];
522
content?: string;
623
title?: string;
7-
infoBoxAlignment?: 'center' | 'left' | 'right';
24+
infoBoxAlignment?: InfoBoxAlignment;
25+
options?: StepOptions;
826
onPrevStep?: () => void;
927
onNextStep?: () => void;
1028
}
1129

12-
export interface Labels {
13-
prev?: string;
14-
next?: string;
15-
skip?: string;
16-
done?: string;
17-
}
18-
1930
export interface Options {
2031
highLightPadding?: number;
2132
infoBoxHeight?: number;

packages/main/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,16 @@ import { tutorial } from './core/tutorial';
22

33
export { TutorialOverlay } from './components/tutorial-overlay';
44
export { tutorial };
5+
export type {
6+
InfoBoxAlignment,
7+
Labels,
8+
Options,
9+
Step,
10+
StepOptions,
11+
Tutorial,
12+
TutorialProgressState,
13+
TutorialResult,
14+
TutorialResultReason,
15+
} from './core/types';
516

617
export default tutorial;

packages/main/test/content.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,50 @@ function renderOverlay() {
1414
}
1515

1616
describe('Content', () => {
17+
test('prefers step labels over global labels and falls back to global or built-in labels', () => {
18+
renderOverlay();
19+
20+
act(() => {
21+
tutorial.open({
22+
steps: [
23+
{
24+
title: 'Step 1',
25+
content: 'Step 1 content',
26+
targetIds: ['first-target'],
27+
options: {
28+
labels: {
29+
skip: 'Leave',
30+
},
31+
},
32+
},
33+
{
34+
title: 'Step 2',
35+
content: 'Step 2 content',
36+
targetIds: ['second-target'],
37+
options: {
38+
labels: {
39+
done: 'Ship it',
40+
},
41+
},
42+
},
43+
],
44+
options: {
45+
labels: {
46+
next: 'Continue',
47+
},
48+
},
49+
});
50+
});
51+
52+
expect(screen.getByRole('button', { name: 'Leave' })).toBeInTheDocument();
53+
expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument();
54+
55+
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
56+
57+
expect(screen.getByRole('button', { name: '이전' })).toBeInTheDocument();
58+
expect(screen.getByRole('button', { name: 'Ship it' })).toBeInTheDocument();
59+
});
60+
1761
test('reads built-in button labels from options.labels', () => {
1862
renderOverlay();
1963

0 commit comments

Comments
 (0)