diff --git a/packages/web/src/tour/Tour.tsx b/packages/web/src/tour/Tour.tsx index c36fd9138c..ef3df10be2 100644 --- a/packages/web/src/tour/Tour.tsx +++ b/packages/web/src/tour/Tour.tsx @@ -117,7 +117,11 @@ export type TourBaseProps = 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; /** @@ -345,17 +349,54 @@ const TourComponent = (_props: TourProps { + 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(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 = () => ( + + + {RenderedTourStep && } + + ); return ( @@ -370,14 +411,14 @@ const TourComponent = (_props: TourProps - {!(activeTourStep.hideOverlay ?? hideOverlay) && activeTourStepTarget && ( + {!isOverlayHidden && activeTourStepTarget && ( (_props: TourProps )} -
- - - - - - +
+ {isOverlayHidden ? ( + renderStepContainer() + ) : ( + {renderStepContainer()} + )}
diff --git a/packages/web/src/tour/__tests__/Tour.test.tsx b/packages/web/src/tour/__tests__/Tour.test.tsx index 1f07baee64..dc4b6e290c 100644 --- a/packages/web/src/tour/__tests__/Tour.test.tsx +++ b/packages/web/src/tour/__tests__/Tour.test.tsx @@ -202,4 +202,66 @@ describe('Tour', () => { }); }); }); + + describe('hideOverlay', () => { + it('drops aria-modal and routes pointer events when hideOverlay is true', () => { + render( + + + , + ); + + 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( + + + +
+ + + , + ); + + 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( + + + , + ); + + const dialog = screen.getByRole('dialog'); + expect(dialog).not.toHaveAttribute('aria-modal'); + }); + + it('keeps aria-modal when hideOverlay is false (default)', () => { + render( + + + , + ); + + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true'); + }); + }); });