From b099c8a2dbd04d9043f385747f0fccbc5d8251e5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:20:43 +0100 Subject: [PATCH] fix: let Enter press the tour's Skip and Back buttons, and mirror its arrow keys in right-to-left languages --- components/tour/__tests__/tour.test.tsx | 40 +++++++++++++++++++++++++ components/tour/tour-overlay.tsx | 16 +++++++--- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/components/tour/__tests__/tour.test.tsx b/components/tour/__tests__/tour.test.tsx index 5f4722774..64bd27601 100644 --- a/components/tour/__tests__/tour.test.tsx +++ b/components/tour/__tests__/tour.test.tsx @@ -88,3 +88,43 @@ describe('tour targets', () => { expect(ids({ supportsContacts: false })).not.toContain('nav-contacts'); }); }); + +describe('tour keyboard', () => { + function showStep() { + anchors(''); + render(); + act(() => { vi.advanceTimersByTime(400); }); + } + + it('lets Enter press the focused Skip button instead of going to the next step', () => { + showStep(); + const skip = screen.getByRole('button', { name: 'tour.skip' }); + skip.focus(); + act(() => { fireEvent.keyDown(skip, { key: 'Enter' }); }); + expect(tour.nextStep).not.toHaveBeenCalled(); + }); + + it('still goes on with Enter when no button has the focus', () => { + showStep(); + act(() => { fireEvent.keyDown(document.body, { key: 'Enter' }); }); + expect(tour.nextStep).toHaveBeenCalledTimes(1); + }); + + it('leaves Enter and the arrows to a field being typed in', () => { + showStep(); + const field = document.createElement('input'); + hosts[0].appendChild(field); + act(() => { fireEvent.keyDown(field, { key: 'Enter' }); }); + act(() => { fireEvent.keyDown(field, { key: 'ArrowRight' }); }); + expect(tour.nextStep).not.toHaveBeenCalled(); + }); + + it('mirrors the arrows in right-to-left languages', () => { + document.documentElement.setAttribute('dir', 'rtl'); + showStep(); + act(() => { fireEvent.keyDown(document.body, { key: 'ArrowLeft' }); }); + expect(tour.nextStep).toHaveBeenCalledTimes(1); + act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight' }); }); + expect(tour.prevStep).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/tour/tour-overlay.tsx b/components/tour/tour-overlay.tsx index cc0ccc7f5..a1b1a8fb3 100644 --- a/components/tour/tour-overlay.tsx +++ b/components/tour/tour-overlay.tsx @@ -6,6 +6,8 @@ import { useTranslations } from "next-intl"; import { useTour } from "./tour-provider"; import { cn } from "@/lib/utils"; import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { isEditableEventTarget } from "@/lib/keyboard"; +import { isDocumentRTL } from "@/i18n/direction"; interface Rect { top: number; @@ -211,7 +213,7 @@ export function TourOverlay() { pendingTimerRef.current = null; } }; - }, [step, currentStep]); // eslint-disable-line react-hooks/exhaustive-deps + }, [step, currentStep]); // Recalculate on resize/scroll (debounced) useEffect(() => { @@ -232,14 +234,20 @@ export function TourOverlay() { }; }, [visible, updatePosition]); - // Keyboard navigation + // Keyboard navigation. Enter on a focused button (Skip, Back) and keys + // typed into a field (the search step) are theirs, not the tour's. useEffect(() => { const handler = (e: KeyboardEvent) => { - if (e.key === "ArrowRight" || e.key === "Enter") { + const onControl = e.target instanceof HTMLElement && !!e.target.closest("button, a[href]"); + const typing = isEditableEventTarget(e); + // The arrow pointing the way you read goes on: → in LTR, ← in RTL. + const forward = isDocumentRTL() ? "ArrowLeft" : "ArrowRight"; + const back = isDocumentRTL() ? "ArrowRight" : "ArrowLeft"; + if ((e.key === forward && !typing) || (e.key === "Enter" && !onControl && !typing)) { e.preventDefault(); e.stopPropagation(); goNext(); - } else if (e.key === "ArrowLeft") { + } else if (e.key === back && !typing) { e.preventDefault(); e.stopPropagation(); goPrev();