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();