Skip to content
Draft
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
87 changes: 59 additions & 28 deletions packages/web/src/tour/Tour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,11 @@ export type TourBaseProps<TourStepId extends string = string> = SharedProps &
*/
TourStepArrowComponent?: TourStepArrowComponent;
/**
* Hide overlay when tour is active
* Hide the overlay/mask while the tour is active. The step still renders
* but as a non-modal coachmark: the underlying page stays scrollable and
* interactive (the root forwards `pointer-events`), `aria-modal` is
* omitted, focus is not trapped, and the step container is focused on
* activation so keyboard users can still reach it.
*/
hideOverlay?: boolean;
/**
Expand Down Expand Up @@ -345,17 +349,54 @@ const TourComponent = <TourStepId extends string = string>(_props: TourProps<Tou
],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First of all, thanks for making this pr!

Maybe it would be better to introduce a separate prop that enables/disables interaction with the background, because hideOverlay was purely a visual preference until this point.

);

// Manages scroll locking for the tour's duration. `useEffect` is used to
// guarantee that scroll is re-enabled when the tour is closed or unmounted.
const isOverlayHidden = activeTourStep?.hideOverlay ?? hideOverlay;

// Scroll is only locked while a modal step is active. Hidden-overlay steps
// intentionally leave the page scrollable. `useEffect` guarantees scroll is
// re-enabled on close, unmount, or transition to a hidden-overlay step.
useEffect(() => {
if (!activeTourStep?.id || isOverlayHidden) return;
blockScroll(true);
return () => blockScroll(false);
}, [activeTourStep?.id, isOverlayHidden, blockScroll]);

// When the overlay is hidden there is no FocusTrap; move focus to the step
// container so keyboard users land inside the coachmark.
const stepContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (activeTourStep?.id) {
blockScroll(true);
if (isOverlayHidden && activeTourStep?.id) {
stepContainerRef.current?.focus();
}
}, [isOverlayHidden, activeTourStep?.id]);

return () => {
blockScroll(false);
};
}, [activeTourStep, animationApi, blockScroll, disableAutoScroll, scrollOptions]);
const rootStyle = useMemo(
() => (isOverlayHidden ? { pointerEvents: 'none' as const, ...styles?.root } : styles?.root),
[isOverlayHidden, styles?.root],
);

const floatingDivStyle = useMemo(
() =>
isOverlayHidden ? { ...floatingStyles, pointerEvents: 'auto' as const } : floatingStyles,
[isOverlayHidden, floatingStyles],
);

const renderStepContainer = () => (
<animated.div
ref={stepContainerRef}
className={cx(tourClassNames.stepContainer, classNames?.stepContainer)}
style={stepContainerStyle}
tabIndex={isOverlayHidden ? -1 : undefined}
>
<RenderedTourStepArrow
ref={tourStepArrowRef}
arrow={arrow}
className={cx(tourClassNames.stepArrow, classNames?.stepArrow)}
placement={placement}
style={styles?.stepArrow}
/>
{RenderedTourStep && <RenderedTourStep {...activeTourStep} />}
</animated.div>
);

return (
<OverlayContentContext.Provider value={overlayContentContextValue}>
Expand All @@ -370,14 +411,14 @@ const TourComponent = <TourStepId extends string = string>(_props: TourProps<Tou
<div
aria-label={accessibilityLabel}
aria-labelledby={accessibilityLabelledBy}
aria-modal="true"
aria-modal={isOverlayHidden ? undefined : 'true'}
className={cx(tourClassNames.root, containerCss, classNames?.root)}
data-testid={testID}
id={id}
role="dialog"
style={styles?.root}
style={rootStyle}
>
{!(activeTourStep.hideOverlay ?? hideOverlay) && activeTourStepTarget && (
{!isOverlayHidden && activeTourStepTarget && (
<animated.div
className={cx(tourClassNames.mask, classNames?.mask)}
style={tourMaskStyles}
Expand All @@ -391,22 +432,12 @@ const TourComponent = <TourStepId extends string = string>(_props: TourProps<Tou
/>
</animated.div>
)}
<div ref={refs.setFloating} style={floatingStyles}>
<FocusTrap>
<animated.div
className={cx(tourClassNames.stepContainer, classNames?.stepContainer)}
style={stepContainerStyle}
>
<RenderedTourStepArrow
ref={tourStepArrowRef}
arrow={arrow}
className={cx(tourClassNames.stepArrow, classNames?.stepArrow)}
placement={placement}
style={styles?.stepArrow}
/>
<RenderedTourStep {...activeTourStep} />
</animated.div>
</FocusTrap>
<div ref={refs.setFloating} style={floatingDivStyle}>
{isOverlayHidden ? (
renderStepContainer()
) : (
<FocusTrap>{renderStepContainer()}</FocusTrap>
)}
</div>
</div>
</Portal>
Expand Down
62 changes: 62 additions & 0 deletions packages/web/src/tour/__tests__/Tour.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,4 +202,66 @@ describe('Tour', () => {
});
});
});

describe('hideOverlay', () => {
it('drops aria-modal and routes pointer events when hideOverlay is true', () => {
render(
<DefaultThemeProvider>
<Tour {...exampleProps} hideOverlay />
</DefaultThemeProvider>,
);

const dialog = screen.getByRole('dialog');
expect(dialog).not.toHaveAttribute('aria-modal');
expect(dialog).toHaveStyle({ pointerEvents: 'none' });

const stepContainer = document.querySelector('.cds-Tour-stepContainer');
const floatingEl = stepContainer?.parentElement;
expect(floatingEl).toHaveStyle({ pointerEvents: 'auto' });
});

it('does not render the mask when hideOverlay is true', async () => {
render(
<DefaultThemeProvider>
<Tour {...exampleProps} hideOverlay>
<TourStep id="step1">
<div />
</TourStep>
</Tour>
</DefaultThemeProvider>,
);

await waitFor(() => {
expect(screen.getByText('Step 1')).toBeInTheDocument();
});
expect(document.querySelector('.cds-Tour-mask')).toBeNull();
});

it('per-step hideOverlay overrides the prop value', () => {
const stepsWithHidden = [{ ...mockTour[0], hideOverlay: true }, mockTour[1], mockTour[2]];
render(
<DefaultThemeProvider>
<Tour
{...exampleProps}
activeTourStep={stepsWithHidden[0]}
hideOverlay={false}
steps={stepsWithHidden}
/>
</DefaultThemeProvider>,
);

const dialog = screen.getByRole('dialog');
expect(dialog).not.toHaveAttribute('aria-modal');
});

it('keeps aria-modal when hideOverlay is false (default)', () => {
render(
<DefaultThemeProvider>
<Tour {...exampleProps} />
</DefaultThemeProvider>,
);

expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
});
});
});
Loading