diff --git a/README.md b/README.md index a96d1dc..5f52c52 100644 --- a/README.md +++ b/README.md @@ -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: () => { @@ -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. diff --git a/packages/document/src/pages/docs/tutorial-overlay.mdx b/packages/document/src/pages/docs/tutorial-overlay.mdx index 68aa0c7..059aaf2 100644 --- a/packages/document/src/pages/docs/tutorial-overlay.mdx +++ b/packages/document/src/pages/docs/tutorial-overlay.mdx @@ -28,6 +28,10 @@ Because `tutorial.open()` returns a Promise, you can keep `` 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. diff --git a/packages/document/src/pages/docs/tutorial.mdx b/packages/document/src/pages/docs/tutorial.mdx index 8a985e5..3ea025e 100644 --- a/packages/document/src/pages/docs/tutorial.mdx +++ b/packages/document/src/pages/docs/tutorial.mdx @@ -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, }, @@ -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. diff --git a/packages/main/src/components/content.tsx b/packages/main/src/components/content.tsx index a47306a..d232618 100644 --- a/packages/main/src/components/content.tsx +++ b/packages/main/src/components/content.tsx @@ -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((_, 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(); @@ -31,11 +33,15 @@ export const Content = React.forwardRef((_, 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, + }} > {currentStep.title} - + {currentStep.content ?? ''} @@ -44,8 +50,8 @@ export const Content = React.forwardRef((_, ref) => { {`${index + 1} / ${steps.length}`} - {index !== 0 && } - + {index !== 0 && } + @@ -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; diff --git a/packages/main/src/components/tutorial-overlay.tsx b/packages/main/src/components/tutorial-overlay.tsx index 81b117b..61bceee 100644 --- a/packages/main/src/components/tutorial-overlay.tsx +++ b/packages/main/src/components/tutorial-overlay.tsx @@ -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])', @@ -34,6 +41,7 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => { const previouslyFocusedElement = useRef(null); const wasOpen = useRef(open); const timeout = useRef(); + const baseZIndex = getBaseZIndex(options); function shouldIgnoreKeyboardEvent(): boolean { const activeElement = document.activeElement; @@ -284,14 +292,26 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => { }; return open ? ( - + {rectStyles.map((style) => ( @@ -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; `; diff --git a/packages/main/src/core/options.ts b/packages/main/src/core/options.ts new file mode 100644 index 0000000..8382276 --- /dev/null +++ b/packages/main/src/core/options.ts @@ -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 = { + prev: '이전', + next: '다음', + skip: '건너뛰기', + done: '완료', +}; + +export function getLabels(options?: Options): Required { + return { + ...DEFAULT_LABELS, + ...options?.labels, + }; +} + +export function getBaseZIndex(options?: Options): number { + return options?.zIndex ?? DEFAULT_Z_INDEX; +} diff --git a/packages/main/src/core/types.ts b/packages/main/src/core/types.ts index 1eb0b64..e3198d8 100644 --- a/packages/main/src/core/types.ts +++ b/packages/main/src/core/types.ts @@ -1,3 +1,5 @@ +export type StyleValue = number | string; + export interface Step { targetIds: string[]; content?: string; @@ -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; @@ -33,5 +48,5 @@ export interface ElementStyle { top: number; width: number; height: number; - borderRadius?: number; + borderRadius?: StyleValue; } diff --git a/packages/main/test/content.test.tsx b/packages/main/test/content.test.tsx index c8ff211..359f8b3 100644 --- a/packages/main/test/content.test.tsx +++ b/packages/main/test/content.test.tsx @@ -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(); diff --git a/packages/main/test/tutorial-overlay.test.tsx b/packages/main/test/tutorial-overlay.test.tsx index a4e2f81..928a5ee 100644 --- a/packages/main/test/tutorial-overlay.test.tsx +++ b/packages/main/test/tutorial-overlay.test.tsx @@ -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', + }); + }); });