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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ Set `options.closeOnOverlayClick` to `true` to close the tutorial when the dimme

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

For accessibility, the info box is exposed as a labeled `dialog`. When the tutorial opens, focus moves into the info box controls, and when it closes, focus returns to the element that was active before open. The library does not currently trap focus inside the overlay.

`options.onClose` still runs whenever the tutorial closes. Use the returned Promise when you need async flow control after the tutorial ends.

Mount `<TutorialOverlay />` once near the root of your app, then trigger `tutorial.open({ steps, options })` from any event handler or effect.
Expand Down
2 changes: 2 additions & 0 deletions packages/document/src/pages/docs/tutorial-overlay.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ By default, the mounted overlay listens for `Escape`, `ArrowLeft`, and `ArrowRig
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.

For accessibility, the info box is rendered as a labeled `dialog`. Opening the tutorial moves focus into the dialog controls, and closing restores focus to the element that was active before `tutorial.open()`. The current overlay does not trap focus inside the dialog.
1 change: 1 addition & 0 deletions packages/document/src/pages/docs/tutorial.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,5 @@ function App() {

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.
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.
32 changes: 21 additions & 11 deletions packages/main/src/components/content.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import React from 'react';
import React, { useId } from 'react';
import { useTutorialStore } from '../core/store';
import { skipTutorial, tutorial } from '../core/tutorial';
import { styled } from 'goober';

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

const handlePrev = () => {
tutorial.prev();
Expand All @@ -23,13 +25,19 @@ export const Content = React.forwardRef((_, ref?: React.ForwardedRef<HTMLInputEl
};

return (
<Wrapper ref={ref}>
<Wrapper
ref={ref}
role="dialog"
aria-labelledby={titleId}
aria-describedby={currentStep.content ? contentId : undefined}
tabIndex={-1}
>
<Heander>
<InfoTitle>
<p>{currentStep.title}</p>
<Title id={titleId}>{currentStep.title}</Title>
<button onClick={handleClose}>건너뛰기</button>
</InfoTitle>
<InfoContent>{currentStep.content ?? ''}</InfoContent>
<InfoContent id={contentId}>{currentStep.content ?? ''}</InfoContent>
</Heander>
<Footer className="flex items-center justify-between">
<InfoSteps className="text-[.75rem] font-medium text-sub-2.5">
Expand All @@ -43,6 +51,7 @@ export const Content = React.forwardRef((_, ref?: React.ForwardedRef<HTMLInputEl
</Wrapper>
);
});
Content.displayName = 'Content';

const Wrapper = styled('div', React.forwardRef)`
position: absolute;
Expand All @@ -64,16 +73,17 @@ const Heander = styled('div')`
flex: 1;
`;

const Title = styled('h2')`
font-size: 1.25rem;
font-weight: 600;
color: #1f1f1f;
margin: 0;
`;

const InfoTitle = styled('div')`
display: flex;
align-items: center;

p {
font-size: 1.25rem;
font-weight: 600;
color: #1f1f1f;
}

button {
font-size: 0.75rem;
font-weight: 500;
Expand Down
41 changes: 39 additions & 2 deletions packages/main/src/components/tutorial-overlay.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { RefObject, useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import type { ElementStyle, Options } from '../core/types';
import { useTutorialStore } from '../core/store';
import { setup, styled } from 'goober';
Expand All @@ -9,6 +9,14 @@ setup(React.createElement);

const DEFAULT_HIGHLIGHT_PADDING = 8;
const MIN_VIEWPORT_OFFSET = 10;
const FOCUSABLE_SELECTOR = [
'button:not([disabled])',
'[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"]):not([disabled])',
].join(', ');

interface TutorialOverlayProps {
options?: Options;
Expand All @@ -23,6 +31,8 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
const [rectStyles, setRectStyles] = useState<ElementStyle[]>([]);
const currentElements = useRef<{ id: string; element: HTMLElement; initialColor: string }[]>([]);
const infoBoxElement = useRef<HTMLDivElement>(null);
const previouslyFocusedElement = useRef<HTMLElement | null>(null);
const wasOpen = useRef(open);
const timeout = useRef<number | undefined>();

function shouldIgnoreKeyboardEvent(): boolean {
Expand Down Expand Up @@ -192,6 +202,32 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
setHighlightedElementPositions();
}, [steps, index]);

useEffect(() => {
if (open && !wasOpen.current) {
const activeElement = document.activeElement;

previouslyFocusedElement.current =
activeElement instanceof HTMLElement && activeElement !== document.body ? activeElement : null;

const dialog = infoBoxElement.current;
if (dialog) {
const focusTarget = dialog.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? dialog;
focusTarget.focus();
}
}

if (!open && wasOpen.current) {
const focusTarget = previouslyFocusedElement.current;
previouslyFocusedElement.current = null;

if (focusTarget && focusTarget.isConnected) {
focusTarget.focus();
}
}

wasOpen.current = open;
}, [open]);

useEffect(() => {
if (!open || options?.keyboardNavigation === false) {
return;
Expand Down Expand Up @@ -249,9 +285,10 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {

return open ? (
<Wrapper data-testid="tutorial-overlay-backdrop" onClick={handleBackdropClick}>
<Content ref={infoBoxElement as RefObject<HTMLInputElement>} />
<Content ref={infoBoxElement} />
{rectStyles.map((style) => (
<Hightlight
aria-hidden="true"
data-testid={`tutorial-overlay-highlight-${style.id}`}
key={style.id}
style={style}
Expand Down
49 changes: 49 additions & 0 deletions packages/main/test/tutorial-overlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,43 @@ describe('TutorialOverlay', () => {
expect(screen.queryByText('Step 1 content')).not.toBeInTheDocument();
});

test('exposes the info box as a labeled dialog', () => {
renderOverlay();
openTutorial();

const dialog = screen.getByRole('dialog', { name: 'Step 1' });

expect(dialog).toBeInTheDocument();
expect(dialog).toHaveAccessibleDescription('Step 1 content');
});

test('moves focus into the dialog when the tutorial opens', () => {
renderOverlay();

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

openTutorial();

expect(screen.getByRole('button', { name: '건너뛰기' })).toHaveFocus();
});

test('restores focus to the previously active element when the tutorial closes', () => {
const onClose = jest.fn();

renderOverlay();

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

openTutorial({ onClose });

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

expect(onClose).toHaveBeenCalledTimes(1);
expect(input).toHaveFocus();
});

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

Expand Down Expand Up @@ -135,6 +172,18 @@ describe('TutorialOverlay', () => {
expect(onClose).not.toHaveBeenCalled();
});

test('keeps keyboard navigation working while focus is on a dialog button', () => {
renderOverlay();
openTutorial();

const skipButton = screen.getByRole('button', { name: '건너뛰기' });
expect(skipButton).toHaveFocus();

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

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

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

Expand Down
Loading