From 91900e9d0d458efc97dcd920ad710f7e66576344 Mon Sep 17 00:00:00 2001 From: sjsjsj1246 Date: Wed, 25 Mar 2026 16:34:03 +0900 Subject: [PATCH] =?UTF-8?q?[-]:=20step=20=EB=8B=A8=EC=9C=84=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=A7=88=EC=9D=B4=EC=A7=95=20override=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 +++++- .../src/pages/docs/tutorial-overlay.mdx | 4 +- packages/document/src/pages/docs/tutorial.mdx | 29 ++++++++--- packages/main/src/components/content.tsx | 6 +-- .../main/src/components/tutorial-overlay.tsx | 19 ++++--- packages/main/src/core/options.ts | 31 +++++++++++- packages/main/src/core/tutorial.ts | 6 +++ packages/main/src/core/types.ts | 27 +++++++--- packages/main/src/index.ts | 11 ++++ packages/main/test/content.test.tsx | 44 ++++++++++++++++ packages/main/test/tutorial-overlay.test.tsx | 50 +++++++++++++++++++ 11 files changed, 212 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 7ea0353..14a60cc 100644 --- a/README.md +++ b/README.md @@ -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: { @@ -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: diff --git a/packages/document/src/pages/docs/tutorial-overlay.mdx b/packages/document/src/pages/docs/tutorial-overlay.mdx index 2454169..cefea5f 100644 --- a/packages/document/src/pages/docs/tutorial-overlay.mdx +++ b/packages/document/src/pages/docs/tutorial-overlay.mdx @@ -28,9 +28,9 @@ 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. +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`. diff --git a/packages/document/src/pages/docs/tutorial.mdx b/packages/document/src/pages/docs/tutorial.mdx index 6c5fcb1..b71d4a9 100644 --- a/packages/document/src/pages/docs/tutorial.mdx +++ b/packages/document/src/pages/docs/tutorial.mdx @@ -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, @@ -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()`. @@ -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. diff --git a/packages/main/src/components/content.tsx b/packages/main/src/components/content.tsx index d232618..7164656 100644 --- a/packages/main/src/components/content.tsx +++ b/packages/main/src/components/content.tsx @@ -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((_, ref) => { const { @@ -12,7 +12,7 @@ export const Content = React.forwardRef((_, ref) => { const currentStep = steps[index]; const titleId = useId(); const contentId = useId(); - const labels = getLabels(options); + const labels = getLabels(options, currentStep); const handlePrev = () => { tutorial.prev(); @@ -34,7 +34,7 @@ export const Content = React.forwardRef((_, 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, }} > diff --git a/packages/main/src/components/tutorial-overlay.tsx b/packages/main/src/components/tutorial-overlay.tsx index 61bceee..0a5545a 100644 --- a/packages/main/src/components/tutorial-overlay.tsx +++ b/packages/main/src/components/tutorial-overlay.tsx @@ -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'; @@ -11,6 +11,10 @@ import { DEFAULT_Z_INDEX, HIGHLIGHT_Z_INDEX_OFFSET, getBaseZIndex, + getHighlightBorderColor, + getHighlightBorderRadius, + getInfoBoxHeight, + getInfoBoxMargin, } from '../core/options'; setup(React.createElement); @@ -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) { @@ -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; @@ -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, }} /> diff --git a/packages/main/src/core/options.ts b/packages/main/src/core/options.ts index 8382276..f654b22 100644 --- a/packages/main/src/core/options.ts +++ b/packages/main/src/core/options.ts @@ -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; @@ -15,13 +17,38 @@ export const DEFAULT_LABELS: Required = { done: '완료', }; -export function getLabels(options?: Options): Required { +export function getLabels(options?: Options, step?: Step): Required { 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; +} diff --git a/packages/main/src/core/tutorial.ts b/packages/main/src/core/tutorial.ts index f8b6406..ec988f4 100644 --- a/packages/main/src/core/tutorial.ts +++ b/packages/main/src/core/tutorial.ts @@ -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, }; diff --git a/packages/main/src/core/types.ts b/packages/main/src/core/types.ts index 987a85b..4244dfa 100644 --- a/packages/main/src/core/types.ts +++ b/packages/main/src/core/types.ts @@ -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; diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index 76cb354..6d9d8c2 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -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; diff --git a/packages/main/test/content.test.tsx b/packages/main/test/content.test.tsx index 359f8b3..723a8b1 100644 --- a/packages/main/test/content.test.tsx +++ b/packages/main/test/content.test.tsx @@ -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(); diff --git a/packages/main/test/tutorial-overlay.test.tsx b/packages/main/test/tutorial-overlay.test.tsx index b0c7d38..a953106 100644 --- a/packages/main/test/tutorial-overlay.test.tsx +++ b/packages/main/test/tutorial-overlay.test.tsx @@ -396,4 +396,54 @@ describe('TutorialOverlay', () => { zIndex: '4322', }); }); + + test('prefers step-level highlight and info box overrides over global options', () => { + renderOverlay(); + mockTargetRect('first-target', { left: 200, top: 550, width: 80, height: 40 }); + + act(() => { + tutorial.open({ + steps: [ + { + title: 'Step 1', + content: 'Step 1 content', + targetIds: ['first-target'], + infoBoxAlignment: 'left', + options: { + infoBoxHeight: 260, + infoBoxWidth: '30rem', + infoBoxMargin: 10, + highlightBorderColor: 'rgb(255, 0, 128)', + highlightBorderRadius: 18, + }, + }, + ], + options: { + overlayColor: 'rgba(12, 34, 56, 0.7)', + infoBoxHeight: 200, + infoBoxWidth: '18rem', + infoBoxMargin: 30, + highlightBorderColor: 'rgb(0, 255, 136)', + highlightBorderRadius: 12, + zIndex: 4321, + }, + }); + }); + + 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: '30rem', + height: '260px', + top: '272px', + left: '192px', + }); + expect(screen.getByTestId('tutorial-overlay-highlight-first-target')).toHaveStyle({ + borderColor: 'rgb(255, 0, 128)', + borderRadius: '18px', + zIndex: '4322', + }); + }); });