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: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,18 @@ const App = () => {
options: {
highLightPadding: 12,
infoBoxHeight: 220,
infoBoxWidth: '24rem',
infoBoxMargin: 24,
overlayColor: 'rgba(15, 23, 42, 0.6)',
highlightBorderColor: '#22c55e',
highlightBorderRadius: 16,
zIndex: 3000,
labels: {
prev: 'Back',
next: 'Continue',
skip: 'Dismiss',
done: 'Finish',
},
keyboardNavigation: true,
closeOnOverlayClick: true,
onClose: () => {
Expand Down Expand Up @@ -87,6 +98,10 @@ const App = () => {

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

Use `labels` to override the built-in button text. The default labels are `이전`, `다음`, `건너뛰기`, and `완료`.

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

- `Escape` closes the tutorial.
Expand Down
4 changes: 4 additions & 0 deletions packages/document/src/pages/docs/tutorial-overlay.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ 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.

Use `options.labels` to replace the built-in `이전`, `다음`, `건너뛰기`, and `완료` button text without replacing the UI itself.

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

Backdrop clicks are ignored by default. Set `options.closeOnOverlayClick = true` if you want clicking the dimmed overlay area to close the tutorial.
Expand Down
17 changes: 17 additions & 0 deletions packages/document/src/pages/docs/tutorial.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,18 @@ function App() {
options: {
highLightPadding: 12,
infoBoxHeight: 220,
infoBoxWidth: '24rem',
infoBoxMargin: 24,
overlayColor: 'rgba(15, 23, 42, 0.6)',
highlightBorderColor: '#22c55e',
highlightBorderRadius: 16,
zIndex: 3000,
labels: {
prev: 'Back',
next: 'Continue',
skip: 'Dismiss',
done: 'Finish',
},
keyboardNavigation: true,
closeOnOverlayClick: true,
},
Expand Down Expand Up @@ -76,7 +87,13 @@ function App() {

- `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.
- `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.
- `zIndex`: sets the overlay stack level. Defaults to `9999`.
- `labels`: overrides the built-in `prev`, `next`, `skip`, and `done` button labels.
- `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 tutorial is closed.
Expand Down
16 changes: 11 additions & 5 deletions packages/main/src/components/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ 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';

export const Content = React.forwardRef<HTMLDivElement>((_, ref) => {
const {
index,
tutorial: { steps },
tutorial: { steps, options },
} = useTutorialStore();
const currentStep = steps[index];
const titleId = useId();
const contentId = useId();
const labels = getLabels(options);

const handlePrev = () => {
tutorial.prev();
Expand All @@ -31,11 +33,15 @@ export const Content = React.forwardRef<HTMLDivElement>((_, ref) => {
aria-labelledby={titleId}
aria-describedby={currentStep.content ? contentId : undefined}
tabIndex={-1}
style={{
width: options?.infoBoxWidth ?? DEFAULT_INFO_BOX_WIDTH,
zIndex: getBaseZIndex(options) + INFO_BOX_Z_INDEX_OFFSET,
}}
>
<Heander>
<InfoTitle>
<Title id={titleId}>{currentStep.title}</Title>
<button onClick={handleClose}>건너뛰기</button>
<button onClick={handleClose}>{labels.skip}</button>
</InfoTitle>
<InfoContent id={contentId}>{currentStep.content ?? ''}</InfoContent>
</Heander>
Expand All @@ -44,8 +50,8 @@ export const Content = React.forwardRef<HTMLDivElement>((_, ref) => {
<span>{`${index + 1} / ${steps.length}`}</span>
</InfoSteps>
<ButtonWrapper className="flex gap-[.625rem]">
{index !== 0 && <button onClick={handlePrev}>이전</button>}
<button onClick={handleNext}>{index === steps.length - 1 ? '완료' : '다음'}</button>
{index !== 0 && <button onClick={handlePrev}>{labels.prev}</button>}
<button onClick={handleNext}>{index === steps.length - 1 ? labels.done : labels.next}</button>
</ButtonWrapper>
</Footer>
</Wrapper>
Expand All @@ -56,7 +62,7 @@ Content.displayName = 'Content';
const Wrapper = styled('div', React.forwardRef)`
position: absolute;
top: 6.25rem;
z-index: 999;
z-index: 10001;
width: 20rem;
min-height: 7.5rem;
display: flex;
Expand Down
34 changes: 27 additions & 7 deletions packages/main/src/components/tutorial-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@ import { useTutorialStore } from '../core/store';
import { setup, styled } from 'goober';
import { Content } from './content';
import { tutorial } from '../core/tutorial';
import {
DEFAULT_HIGHLIGHT_BORDER_COLOR,
DEFAULT_HIGHLIGHT_PADDING,
DEFAULT_OVERLAY_COLOR,
DEFAULT_Z_INDEX,
HIGHLIGHT_Z_INDEX_OFFSET,
getBaseZIndex,
} from '../core/options';

setup(React.createElement);

const DEFAULT_HIGHLIGHT_PADDING = 8;
const MIN_VIEWPORT_OFFSET = 10;
const FOCUSABLE_SELECTOR = [
'button:not([disabled])',
Expand All @@ -34,6 +41,7 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
const previouslyFocusedElement = useRef<HTMLElement | null>(null);
const wasOpen = useRef(open);
const timeout = useRef<number | undefined>();
const baseZIndex = getBaseZIndex(options);

function shouldIgnoreKeyboardEvent(): boolean {
const activeElement = document.activeElement;
Expand Down Expand Up @@ -284,14 +292,26 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
};

return open ? (
<Wrapper data-testid="tutorial-overlay-backdrop" onClick={handleBackdropClick}>
<Wrapper
data-testid="tutorial-overlay-backdrop"
onClick={handleBackdropClick}
style={{
backgroundColor: options?.overlayColor ?? DEFAULT_OVERLAY_COLOR,
zIndex: baseZIndex,
}}
>
<Content ref={infoBoxElement} />
{rectStyles.map((style) => (
<Hightlight
aria-hidden="true"
data-testid={`tutorial-overlay-highlight-${style.id}`}
key={style.id}
style={style}
style={{
...style,
borderColor: options?.highlightBorderColor ?? DEFAULT_HIGHLIGHT_BORDER_COLOR,
borderRadius: options?.highlightBorderRadius ?? style.borderRadius,
zIndex: baseZIndex + HIGHLIGHT_Z_INDEX_OFFSET,
}}
/>
))}
</Wrapper>
Expand All @@ -302,16 +322,16 @@ const Wrapper = styled('div')`
position: fixed;
top: 0;
left: 0;
z-index: 9999;
z-index: ${DEFAULT_Z_INDEX};
height: 100vh;
width: 100vw;
background-color: rgba(0, 0, 0, 0.5);
background-color: ${DEFAULT_OVERLAY_COLOR};
`;

const Hightlight = styled('div')`
position: absolute;
z-index: 9999;
z-index: ${DEFAULT_Z_INDEX + HIGHLIGHT_Z_INDEX_OFFSET};
box-sizing: border-box;
border: 2px solid #ff0000;
border: 2px solid ${DEFAULT_HIGHLIGHT_BORDER_COLOR};
border-radius: 0.625rem;
`;
27 changes: 27 additions & 0 deletions packages/main/src/core/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Labels, Options } 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_WIDTH = '20rem';
export const DEFAULT_Z_INDEX = 9999;
export const HIGHLIGHT_Z_INDEX_OFFSET = 1;
export const INFO_BOX_Z_INDEX_OFFSET = 2;

export const DEFAULT_LABELS: Required<Labels> = {
prev: '이전',
next: '다음',
skip: '건너뛰기',
done: '완료',
};

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

export function getBaseZIndex(options?: Options): number {
return options?.zIndex ?? DEFAULT_Z_INDEX;
}
17 changes: 16 additions & 1 deletion packages/main/src/core/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export type StyleValue = number | string;

export interface Step {
targetIds: string[];
content?: string;
Expand All @@ -7,10 +9,23 @@ export interface Step {
onNextStep?: () => void;
}

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

export interface Options {
highLightPadding?: number;
infoBoxHeight?: number;
infoBoxWidth?: StyleValue;
infoBoxMargin?: number;
overlayColor?: string;
highlightBorderColor?: string;
highlightBorderRadius?: StyleValue;
zIndex?: number;
labels?: Labels;
keyboardNavigation?: boolean;
closeOnOverlayClick?: boolean;
onClose?: () => void;
Expand All @@ -33,5 +48,5 @@ export interface ElementStyle {
top: number;
width: number;
height: number;
borderRadius?: number;
borderRadius?: StyleValue;
}
29 changes: 29 additions & 0 deletions packages/main/test/content.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,35 @@ function renderOverlay() {
}

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

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

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

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

expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Finish' })).toBeInTheDocument();
});

test('button navigation invokes each step callback once', () => {
const onNextStep = jest.fn();
const onPrevStep = jest.fn();
Expand Down
27 changes: 27 additions & 0 deletions packages/main/test/tutorial-overlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,4 +345,31 @@ describe('TutorialOverlay', () => {

jest.useRealTimers();
});

test('applies custom overlay, highlight, and info box styles from options', () => {
renderOverlay();
mockTargetRect('first-target', { left: 120, top: 96, width: 140, height: 48 });

openTutorial({
overlayColor: 'rgba(12, 34, 56, 0.7)',
highlightBorderColor: 'rgb(0, 255, 136)',
highlightBorderRadius: 24,
zIndex: 4321,
infoBoxWidth: '28rem',
});

expect(screen.getByTestId('tutorial-overlay-backdrop')).toHaveStyle({
backgroundColor: 'rgba(12, 34, 56, 0.7)',
zIndex: '4321',
});
expect(screen.getByRole('dialog', { name: 'Step 1' })).toHaveStyle({
width: '28rem',
zIndex: '4323',
});
expect(screen.getByTestId('tutorial-overlay-highlight-first-target')).toHaveStyle({
borderColor: 'rgb(0, 255, 136)',
borderRadius: '24px',
zIndex: '4322',
});
});
});
Loading