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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ const App = () => {
options: {
infoBoxHeight: 220,
infoBoxMargin: 24,
keyboardNavigation: true,
closeOnOverlayClick: true,
onClose: () => {
console.log('tutorial closed');
},
Expand All @@ -71,6 +73,16 @@ const App = () => {

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

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

- `Escape` closes the tutorial.
- `ArrowRight` moves to the next step and completes the tutorial on the last step.
- `ArrowLeft` moves to the previous step and is a no-op on the first step.

Set `options.keyboardNavigation` to `false` to disable those shortcuts. Shortcuts are ignored while an `input`, `textarea`, `select`, or `contenteditable` element has focus.

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.

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 @@ -23,3 +23,7 @@ function App() {
```

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

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.
6 changes: 6 additions & 0 deletions packages/document/src/pages/docs/tutorial.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ function App() {
options: {
infoBoxHeight: 220,
infoBoxMargin: 24,
keyboardNavigation: true,
closeOnOverlayClick: true,
},
});
}, []);
Expand Down Expand Up @@ -61,4 +63,8 @@ function App() {

- `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.
64 changes: 62 additions & 2 deletions packages/main/src/components/tutorial-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ElementStyle, Options } from '../core/types';
import { useTutorialStore } from '../core/store';
import { setup, styled } from 'goober';
import { Content } from './content';
import { tutorial } from '../core/tutorial';

setup(React.createElement);

Expand All @@ -21,6 +22,21 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
const infoBoxElement = useRef<HTMLDivElement>(null);
const timeout = useRef<number | undefined>();

function shouldIgnoreKeyboardEvent(): boolean {
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement)) {
return false;
}

const tagName = activeElement.tagName;
return (
activeElement.isContentEditable ||
tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
tagName === 'SELECT'
);
}

function resetHighlightedElements(): void {
currentElements.current.forEach((item) => {
item.element.classList.remove('foreground');
Expand Down Expand Up @@ -131,6 +147,40 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
setHighlightedElementPositions();
}, [steps, index]);

useEffect(() => {
if (!open || options?.keyboardNavigation === false) {
return;
}

function handleKeyDown(event: KeyboardEvent) {
if (shouldIgnoreKeyboardEvent()) {
return;
}

switch (event.key) {
case 'Escape':
event.preventDefault();
tutorial.close();
break;
case 'ArrowRight':
event.preventDefault();
tutorial.next();
break;
case 'ArrowLeft':
event.preventDefault();
tutorial.prev();
break;
default:
break;
}
}

window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [open, options?.keyboardNavigation]);

useEffect(() => {
function handleResize() {
clearTimeout(timeout.current);
Expand All @@ -146,11 +196,21 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
};
}, [steps, index, options]);

const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (options?.closeOnOverlayClick && event.target === event.currentTarget) {
tutorial.close();
}
};

return open ? (
<Wrapper>
<Wrapper data-testid="tutorial-overlay-backdrop" onClick={handleBackdropClick}>
<Content ref={infoBoxElement as RefObject<HTMLInputElement>} />
{rectStyles.map((style) => (
<Hightlight key={style.id} style={style} />
<Hightlight
data-testid={`tutorial-overlay-highlight-${style.id}`}
key={style.id}
style={style}
/>
))}
</Wrapper>
) : null;
Expand Down
2 changes: 2 additions & 0 deletions packages/main/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface Options {
highLightPadding?: number;
infoBoxHeight?: number;
infoBoxMargin?: number;
keyboardNavigation?: boolean;
closeOnOverlayClick?: boolean;
onClose?: () => void;
}

Expand Down
132 changes: 131 additions & 1 deletion packages/main/test/tutorial-overlay.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
import React from 'react';
import { act, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { TutorialOverlay } from '../src/components/tutorial-overlay';
import { tutorial } from '../src/core/tutorial';
import type { Options } from '../src/core/types';

function renderOverlay() {
render(
<div>
<input aria-label="Page input" />
<div id="first-target">First target</div>
<div id="second-target">Second target</div>
<TutorialOverlay />
</div>
);
}

function openTutorial(options: Options = {}) {
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,
});
});
}

describe('TutorialOverlay', () => {
test('stays mounted when a target element cannot be found', () => {
Expand All @@ -27,4 +59,102 @@ describe('TutorialOverlay', () => {
expect(screen.getByText('1 / 1')).toBeInTheDocument();
expect(errorSpy).toHaveBeenCalledWith('Highlighted element with id does-not-exist was not found.');
});

test('supports keyboard navigation by default', () => {
const onClose = jest.fn();

renderOverlay();
openTutorial({ onClose });

fireEvent.keyDown(window, { key: 'ArrowRight' });

expect(screen.getByText('Step 2 content')).toBeInTheDocument();

fireEvent.keyDown(window, { key: 'ArrowLeft' });

expect(screen.getByText('Step 1 content')).toBeInTheDocument();

fireEvent.keyDown(window, { key: 'Escape' });

expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByText('Step 1 content')).not.toBeInTheDocument();
});

test('keeps first step on ArrowLeft and closes after ArrowRight on the last step', () => {
const onClose = jest.fn();

renderOverlay();
openTutorial({ onClose });

fireEvent.keyDown(window, { key: 'ArrowLeft' });

expect(screen.getByText('Step 1 content')).toBeInTheDocument();

fireEvent.keyDown(window, { key: 'ArrowRight' });
fireEvent.keyDown(window, { key: 'ArrowRight' });

expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByText('Step 2 content')).not.toBeInTheDocument();
});

test('does not handle keyboard shortcuts when keyboardNavigation is disabled', () => {
const onClose = jest.fn();

renderOverlay();
openTutorial({ keyboardNavigation: false, onClose });

fireEvent.keyDown(window, { key: 'ArrowRight' });
fireEvent.keyDown(window, { key: 'Escape' });

expect(screen.getByText('Step 1 content')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});

test('ignores keyboard shortcuts while a text input has focus', () => {
const onClose = jest.fn();

renderOverlay();
openTutorial({ onClose });

const input = screen.getByLabelText('Page input');
input.focus();

fireEvent.keyDown(input, { key: 'ArrowRight' });
fireEvent.keyDown(input, { key: 'Escape' });

expect(screen.getByText('Step 1 content')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});

test('closes on backdrop click only when closeOnOverlayClick is enabled', () => {
const onClose = jest.fn();

renderOverlay();
openTutorial({ closeOnOverlayClick: true, onClose });

fireEvent.click(screen.getByTestId('tutorial-overlay-highlight-first-target'));

expect(screen.getByText('Step 1 content')).toBeInTheDocument();

fireEvent.click(screen.getByText('Step 1 content'));

expect(screen.getByText('Step 1 content')).toBeInTheDocument();

fireEvent.click(screen.getByTestId('tutorial-overlay-backdrop'));

expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByText('Step 1 content')).not.toBeInTheDocument();
});

test('does not close on backdrop click by default', () => {
const onClose = jest.fn();

renderOverlay();
openTutorial({ onClose });

fireEvent.click(screen.getByTestId('tutorial-overlay-backdrop'));

expect(screen.getByText('Step 1 content')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
});
Loading