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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const App = () => {
},
],
options: {
highLightPadding: 12,
infoBoxHeight: 220,
infoBoxMargin: 24,
keyboardNavigation: true,
Expand All @@ -73,6 +74,8 @@ const App = () => {

`content` is rendered as a plain string. HTML markup in the string is not interpreted.

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

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

- `Escape` closes the tutorial.
Expand All @@ -83,6 +86,8 @@ Set `options.keyboardNavigation` to `false` to disable those shortcuts. Shortcut

Set `options.closeOnOverlayClick` to `true` to close the tutorial when the dimmed backdrop itself is clicked. Clicks on the highlight frame and info box do not trigger close.

The info box automatically flips and clamps itself to stay inside the viewport when the target sits close to an edge.

Mount `<TutorialOverlay />` once near the root of your app, then trigger `tutorial.open({ steps, options })` from any event handler or effect.

## Documentation
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 @@ -24,6 +24,10 @@ function App() {

`TutorialOverlay` does not need props for the current public API. Configure behavior through `tutorial.open({ steps, options })`.

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.

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.

The info box automatically flips and clamps itself to stay visible when the highlighted target sits near the viewport edges.
3 changes: 3 additions & 0 deletions packages/document/src/pages/docs/tutorial.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function App() {
},
],
options: {
highLightPadding: 12,
infoBoxHeight: 220,
infoBoxMargin: 24,
keyboardNavigation: true,
Expand Down Expand Up @@ -61,10 +62,12 @@ function App() {

## Tutorial options

- `highLightPadding`: expands the highlight frame around the target in pixels. Defaults to `8`.
- `infoBoxHeight`: sets the info box height in pixels.
- `infoBoxMargin`: controls the vertical gap between the target and the info box.
- `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.

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.
77 changes: 61 additions & 16 deletions packages/main/src/components/tutorial-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { tutorial } from '../core/tutorial';

setup(React.createElement);

const DEFAULT_HIGHLIGHT_PADDING = 8;
const MIN_VIEWPORT_OFFSET = 10;

interface TutorialOverlayProps {
options?: Options;
}
Expand Down Expand Up @@ -44,6 +47,18 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
currentElements.current = [];
}

function getHighlightPadding(): number {
return Math.max(0, options?.highLightPadding ?? DEFAULT_HIGHLIGHT_PADDING);
}

function clamp(value: number, min: number, max: number): number {
if (max < min) {
return min;
}

return Math.min(Math.max(value, min), max);
}

function setHighlightedElementPositions() {
const stepConfig = steps[index];
const elementIds = stepConfig?.targetIds;
Expand All @@ -58,12 +73,14 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
}[] = [];

const alreadyCalculated = elementIds[0] === currentElements.current[0]?.id;
const highlightPadding = getHighlightPadding();
let infoBoxAnchor: ElementStyle | null = null;

if (!alreadyCalculated) {
resetHighlightedElements();
}

elementIds.forEach((id: string, index: number) => {
elementIds.forEach((id: string) => {
const element: HTMLElement | null = document.getElementById(id);
if (!element) {
console.error(`Highlighted element with id ${id} was not found.`);
Expand All @@ -85,17 +102,23 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
if (selectedElPosition) {
const position: ElementStyle = {
id: id,
left: selectedElPosition.left + window.scrollX - 1,
top: selectedElPosition.top + window.scrollY - 1,
width: selectedElPosition.width + 2,
height: selectedElPosition.height + 2,
left: selectedElPosition.left + window.scrollX - highlightPadding,
top: selectedElPosition.top + window.scrollY - highlightPadding,
width: selectedElPosition.width + highlightPadding * 2,
height: selectedElPosition.height + highlightPadding * 2,
borderRadius: Math.max(10, highlightPadding + 2),
};
positions.push(position);
if (index === 0) {
calculateInfoBoxPosition(position, stepConfig.infoBoxAlignment);
if (!infoBoxAnchor) {
infoBoxAnchor = position;
}
}
});

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

if (currentElements.current.length === 0 || !alreadyCalculated) {
currentElements.current = elements;
}
Expand All @@ -106,25 +129,47 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
function calculateInfoBoxPosition(position: ElementStyle, alignment?: 'center' | 'left' | 'right') {
const boxHeight = options?.infoBoxHeight ?? 200;
const margin = options?.infoBoxMargin ?? 30;
const minLeft = window.scrollX + MIN_VIEWPORT_OFFSET;
const maxLeft = window.scrollX + window.innerWidth - MIN_VIEWPORT_OFFSET;
const minTop = window.scrollY + MIN_VIEWPORT_OFFSET;
const maxTop = window.scrollY + window.innerHeight - MIN_VIEWPORT_OFFSET;

let newBoxTop = position.top - boxHeight - margin;
if (newBoxTop < 10) {
newBoxTop = position.top + position.height + margin;
}
const fallbackBoxTop = position.top + position.height + margin;

const el = infoBoxElement.current;
if (el) {
el.style.height = boxHeight + 'px';

const boxWidth = el.getBoundingClientRect().width || el.clientWidth;
let newBoxLeft: number;

if (alignment === 'left') {
newBoxLeft = position.left < 10 ? 10 : position.left;
newBoxLeft = position.left;
} else if (alignment === 'right') {
newBoxLeft = position.left + position.width - el.clientWidth;
newBoxLeft = position.left + position.width - boxWidth;
} else {
newBoxLeft = position.left + position.width / 2;
const halfOfBoxWidth = el.clientWidth / 2;
newBoxLeft = newBoxLeft - halfOfBoxWidth < 10 ? 10 + halfOfBoxWidth : newBoxLeft;
const halfOfBoxWidth = boxWidth / 2;
newBoxLeft = clamp(newBoxLeft, minLeft + halfOfBoxWidth, maxLeft - halfOfBoxWidth);
}
el.style.height = boxHeight + 'px';

if (alignment !== 'center') {
newBoxLeft = clamp(newBoxLeft, minLeft, maxLeft - boxWidth);
}

const maxBoxTop = maxTop - boxHeight;
if (newBoxTop < minTop) {
newBoxTop = fallbackBoxTop;
}
if (newBoxTop > maxBoxTop) {
if (position.top - boxHeight - margin >= minTop) {
newBoxTop = position.top - boxHeight - margin;
} else {
newBoxTop = clamp(newBoxTop, minTop, maxBoxTop);
}
}

el.style.top = newBoxTop + 'px';
el.style.left = newBoxLeft + 'px';
el.style.transform = alignment === 'center' ? 'translate(-50%)' : '';
Expand Down Expand Up @@ -229,7 +274,7 @@ const Wrapper = styled('div')`
const Hightlight = styled('div')`
position: absolute;
z-index: 9999;
box-sizing: border-box;
border: 2px solid #ff0000;
border-radius: 0.625rem;
transform: translate(-1px, -1px);
`;
1 change: 1 addition & 0 deletions packages/main/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ export interface ElementStyle {
top: number;
width: number;
height: number;
borderRadius?: number;
}
139 changes: 139 additions & 0 deletions packages/main/test/tutorial-overlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { TutorialOverlay } from '../src/components/tutorial-overlay';
import { tutorial } from '../src/core/tutorial';
import type { Options } from '../src/core/types';

const DEFAULT_HIGHLIGHT_PADDING = 8;

function renderOverlay() {
render(
<div>
Expand Down Expand Up @@ -35,6 +37,29 @@ function openTutorial(options: Options = {}) {
});
}

function createDomRect({ left, top, width, height }: { left: number; top: number; width: number; height: number }): DOMRect {
return {
x: left,
y: top,
left,
top,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => '',
} as DOMRect;
}

function mockTargetRect(id: string, rect: { left: number; top: number; width: number; height: number }) {
const element = document.getElementById(id) as HTMLElement;
element.getBoundingClientRect = jest.fn(() => createDomRect(rect));
}

function getInfoBoxElement(): HTMLDivElement {
return screen.getByText('Step 1').closest('div')?.parentElement?.parentElement as HTMLDivElement;
}

describe('TutorialOverlay', () => {
test('stays mounted when a target element cannot be found', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
Expand Down Expand Up @@ -157,4 +182,118 @@ describe('TutorialOverlay', () => {
expect(screen.getByText('Step 1 content')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});

test('applies the default highlight padding to the calculated rect', () => {
renderOverlay();
mockTargetRect('first-target', { left: 100, top: 80, width: 120, height: 40 });

openTutorial();

expect(screen.getByTestId('tutorial-overlay-highlight-first-target')).toHaveStyle({
left: `${100 - DEFAULT_HIGHLIGHT_PADDING}px`,
top: `${80 - DEFAULT_HIGHLIGHT_PADDING}px`,
width: `${120 + DEFAULT_HIGHLIGHT_PADDING * 2}px`,
height: `${40 + DEFAULT_HIGHLIGHT_PADDING * 2}px`,
});
});

test('applies a custom highlight padding to the calculated rect', () => {
renderOverlay();
mockTargetRect('first-target', { left: 200, top: 160, width: 80, height: 32 });

openTutorial({ highLightPadding: 16 });

expect(screen.getByTestId('tutorial-overlay-highlight-first-target')).toHaveStyle({
left: '184px',
top: '144px',
width: '112px',
height: '64px',
});
});

test('keeps the info box inside the viewport when padding expands the target rect', () => {
jest.useFakeTimers();
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 800,
});
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: 600,
});

renderOverlay();
mockTargetRect('first-target', { left: 760, top: 100, width: 40, height: 40 });

act(() => {
tutorial.open({
steps: [
{
title: 'Step 1',
content: 'Step 1 content',
targetIds: ['first-target'],
infoBoxAlignment: 'right',
},
],
options: {
highLightPadding: 8,
},
});
});

const infoBox = getInfoBoxElement();
Object.defineProperty(infoBox, 'clientWidth', {
configurable: true,
value: 320,
});
Object.defineProperty(infoBox, 'clientHeight', {
configurable: true,
value: 200,
});

act(() => {
window.dispatchEvent(new Event('resize'));
jest.advanceTimersByTime(300);
});

expect(infoBox.style.left).toBe('470px');

jest.useRealTimers();
});

test('clamps the info box vertically when neither side has enough space', () => {
jest.useFakeTimers();
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 800,
});
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: 600,
});

renderOverlay();
mockTargetRect('first-target', { left: 120, top: 100, width: 80, height: 40 });

openTutorial({ infoBoxHeight: 520 });

const infoBox = getInfoBoxElement();
Object.defineProperty(infoBox, 'clientWidth', {
configurable: true,
value: 320,
});
Object.defineProperty(infoBox, 'clientHeight', {
configurable: true,
value: 520,
});

act(() => {
window.dispatchEvent(new Event('resize'));
jest.advanceTimersByTime(300);
});

expect(infoBox.style.top).toBe('70px');

jest.useRealTimers();
});
});
Loading