From a343a9035608d27c37fd29e3e936e1d806c51035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B7=AF=E6=8C=AF=E5=87=AF?= Date: Tue, 30 Sep 2025 14:18:59 +0800 Subject: [PATCH 1/7] feat: support keyborad change --- src/Tour.tsx | 52 +++++++++++++++++++++++++++++++++++++++++------- src/interface.ts | 1 + 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/Tour.tsx b/src/Tour.tsx index 7c4afeb..a624126 100644 --- a/src/Tour.tsx +++ b/src/Tour.tsx @@ -4,7 +4,8 @@ import type { TriggerRef } from '@rc-component/trigger'; import Trigger from '@rc-component/trigger'; import { clsx } from 'clsx'; import useLayoutEffect from '@rc-component/util/lib/hooks/useLayoutEffect'; -import useMergedState from '@rc-component/util/lib/hooks/useMergedState'; +import useEvent from '@rc-component/util/lib/hooks/useEvent'; +import KeyCode from '@rc-component/util/lib/KeyCode'; import useControlledState from '@rc-component/util/lib/hooks/useControlledState'; import { useMemo } from 'react'; import { useClosable } from './hooks/useClosable'; @@ -35,6 +36,7 @@ const Tour: React.FC = props => { steps = [], defaultCurrent, current, + keyboard = true, onChange, onClose, onFinish, @@ -88,7 +90,7 @@ const Tour: React.FC = props => { setHasOpened(true); } openRef.current = mergedOpen; - }, [mergedOpen]); + }, [mergedOpen, setMergedCurrent]); const { target, @@ -156,6 +158,47 @@ const Tour: React.FC = props => { } return getPlacements(arrowPointAtCenter); }, [builtinPlacements, arrowPointAtCenter]); + const handleClose = () => { + setMergedOpen(false); + onClose?.(mergedCurrent); + }; + + // ========================= Keyboard ========================= + // Support Esc to close (if closable) and ArrowLeft/ArrowRight to navigate steps. + const keyboardHandler = useEvent((e: KeyboardEvent) => { + if (keyboard && e.keyCode === KeyCode.ESC) { + if (mergedClosable !== null) { + e.stopPropagation(); + e.preventDefault(); + handleClose(); + } + return; + } + + if (keyboard && e.keyCode === KeyCode.LEFT) { + if (mergedCurrent > 0) { + e.preventDefault(); + onInternalChange(mergedCurrent - 1); + } + return; + } + + if (keyboard && e.keyCode === KeyCode.RIGHT) { + e.preventDefault(); + if (mergedCurrent < steps.length - 1) { + onInternalChange(mergedCurrent + 1); + } + return; + } + }); + + useLayoutEffect(() => { + if (!mergedOpen) return; + window.addEventListener('keydown', keyboardHandler); + return () => { + window.removeEventListener('keydown', keyboardHandler); + }; + }, [mergedOpen, keyboardHandler]); // ========================= Render ========================= // Skip if not init yet @@ -163,11 +206,6 @@ const Tour: React.FC = props => { return null; } - const handleClose = () => { - setMergedOpen(false); - onClose?.(mergedCurrent); - }; - const getPopupElement = () => ( { style?: React.CSSProperties; steps?: TourStepInfo[]; open?: boolean; + keyboard?: boolean; defaultOpen?: boolean; defaultCurrent?: number; current?: number; From 551f26c7ef6aaf0592ea77f4227616e4c3733f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B7=AF=E6=8C=AF=E5=87=AF?= Date: Tue, 30 Sep 2025 14:37:25 +0800 Subject: [PATCH 2/7] update: editable target exclude --- src/Tour.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Tour.tsx b/src/Tour.tsx index a624126..7260720 100644 --- a/src/Tour.tsx +++ b/src/Tour.tsx @@ -166,6 +166,17 @@ const Tour: React.FC = props => { // ========================= Keyboard ========================= // Support Esc to close (if closable) and ArrowLeft/ArrowRight to navigate steps. const keyboardHandler = useEvent((e: KeyboardEvent) => { + // Ignore keyboard events from input-like elements to avoid interfering when typing + const el = e.target as HTMLElement | null; + if ( + el?.tagName === 'INPUT' || + el?.tagName === 'TEXTAREA' || + el?.tagName === 'SELECT' || + el?.isContentEditable + ) { + return; + } + if (keyboard && e.keyCode === KeyCode.ESC) { if (mergedClosable !== null) { e.stopPropagation(); @@ -184,8 +195,8 @@ const Tour: React.FC = props => { } if (keyboard && e.keyCode === KeyCode.RIGHT) { - e.preventDefault(); if (mergedCurrent < steps.length - 1) { + e.preventDefault(); onInternalChange(mergedCurrent + 1); } return; From 770683746609da9a1a5a8914652e6b39aa5af6cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B7=AF=E6=8C=AF=E5=87=AF?= Date: Tue, 30 Dec 2025 15:51:48 +0800 Subject: [PATCH 3/7] add isEditableTarget --- package.json | 2 +- src/Tour.tsx | 18 ++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 3fe901c..829f0cc 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "dependencies": { "@rc-component/portal": "^2.0.0", "@rc-component/trigger": "^3.0.0", - "@rc-component/util": "^1.3.0", + "@rc-component/util": "^1.7.0", "clsx": "^2.1.1" }, "devDependencies": { diff --git a/src/Tour.tsx b/src/Tour.tsx index 7260720..25ed808 100644 --- a/src/Tour.tsx +++ b/src/Tour.tsx @@ -167,17 +167,11 @@ const Tour: React.FC = props => { // Support Esc to close (if closable) and ArrowLeft/ArrowRight to navigate steps. const keyboardHandler = useEvent((e: KeyboardEvent) => { // Ignore keyboard events from input-like elements to avoid interfering when typing - const el = e.target as HTMLElement | null; - if ( - el?.tagName === 'INPUT' || - el?.tagName === 'TEXTAREA' || - el?.tagName === 'SELECT' || - el?.isContentEditable - ) { - return; - } + if (KeyCode.isEditableTarget(e)) { + return; + } - if (keyboard && e.keyCode === KeyCode.ESC) { + if (keyboard && e.key === 'Escape') { if (mergedClosable !== null) { e.stopPropagation(); e.preventDefault(); @@ -186,7 +180,7 @@ const Tour: React.FC = props => { return; } - if (keyboard && e.keyCode === KeyCode.LEFT) { + if (keyboard && e.key === 'ArrowLeft') { if (mergedCurrent > 0) { e.preventDefault(); onInternalChange(mergedCurrent - 1); @@ -194,7 +188,7 @@ const Tour: React.FC = props => { return; } - if (keyboard && e.keyCode === KeyCode.RIGHT) { + if (keyboard && e.key === 'ArrowRight') { if (mergedCurrent < steps.length - 1) { e.preventDefault(); onInternalChange(mergedCurrent + 1); From 2a1085267cc65d9445eb15c78e11d35c5395c977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B7=AF=E6=8C=AF=E5=87=AF?= Date: Thu, 8 Jan 2026 08:36:15 +0800 Subject: [PATCH 4/7] use Portal esc --- src/Tour.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/Tour.tsx b/src/Tour.tsx index 25ed808..f554a8f 100644 --- a/src/Tour.tsx +++ b/src/Tour.tsx @@ -171,15 +171,6 @@ const Tour: React.FC = props => { return; } - if (keyboard && e.key === 'Escape') { - if (mergedClosable !== null) { - e.stopPropagation(); - e.preventDefault(); - handleClose(); - } - return; - } - if (keyboard && e.key === 'ArrowLeft') { if (mergedCurrent > 0) { e.preventDefault(); From 1ff54a9a05888f614ea7790e8f12fd368c72f858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B7=AF=E6=8C=AF=E5=87=AF?= Date: Thu, 8 Jan 2026 10:31:55 +0800 Subject: [PATCH 5/7] use portal esc --- package.json | 2 +- src/Mask.tsx | 3 +++ src/Tour.tsx | 12 +++++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 829f0cc..fd6a250 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "watch": "father dev" }, "dependencies": { - "@rc-component/portal": "^2.0.0", + "@rc-component/portal": "^2.2.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.7.0", "clsx": "^2.1.1" diff --git a/src/Mask.tsx b/src/Mask.tsx index a6c8199..637f987 100644 --- a/src/Mask.tsx +++ b/src/Mask.tsx @@ -25,6 +25,7 @@ export interface MaskProps { classNames?: Partial>; styles?: Partial>; getPopupContainer?: TourProps['getPopupContainer']; + onEsc?: (info: { top: boolean; event: KeyboardEvent }) => void; } const Mask: React.FC = props => { @@ -42,6 +43,7 @@ const Mask: React.FC = props => { styles, classNames: tourClassNames, getPopupContainer, + onEsc, } = props; const id = useId(); @@ -63,6 +65,7 @@ const Mask: React.FC = props => { open={open} autoLock={!inlineMode} getContainer={getPopupContainer as any} + onEsc={onEsc} >
= props => { onClose?.(mergedCurrent); }; + // ========================= Esc Close ========================= + // Use Portal's onEsc to handle Escape key with proper stacking logic + const handleEscClose = useEvent(({ event }: { top: boolean; event: KeyboardEvent }) => { + if (keyboard && mergedClosable !== null) { + event.preventDefault(); + handleClose(); + } + }); + // ========================= Keyboard ========================= - // Support Esc to close (if closable) and ArrowLeft/ArrowRight to navigate steps. + // Support ArrowLeft/ArrowRight to navigate steps. const keyboardHandler = useEvent((e: KeyboardEvent) => { // Ignore keyboard events from input-like elements to avoid interfering when typing if (KeyCode.isEditableTarget(e)) { @@ -254,6 +263,7 @@ const Tour: React.FC = props => { animated={animated} rootClassName={rootClassName} disabledInteraction={disabledInteraction} + onEsc={handleEscClose} /> Date: Fri, 9 Jan 2026 09:58:24 +0800 Subject: [PATCH 6/7] add test case --- tests/index.test.tsx | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 6f27163..24608d4 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -1289,4 +1289,38 @@ describe('Tour', () => { height: 0, }); }); + + describe('keyboard ESC to close', () => { + it('should close tour when press ESC', () => { + const onClose = jest.fn(); + const Demo = () => { + const [open, setOpen] = useState(true); + return ( + { + setOpen(false); + onClose(current); + }} + steps={[ + { + title: '创建', + description: '创建一条数据', + }, + ]} + /> + ); + }; + + render(); + + expect(document.querySelector('.rc-tour')).toBeTruthy(); + + // Press ESC key + fireEvent.keyDown(window, { key: 'Escape' }); + + expect(onClose).toHaveBeenCalledWith(0); + expect(document.querySelector('.rc-tour')).toBeFalsy(); + }); + }); }); From c7163147d9fe7439e62c2df617f2a12084ac602b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B7=AF=E6=8C=AF=E5=87=AF?= Date: Fri, 9 Jan 2026 16:26:23 +0800 Subject: [PATCH 7/7] add test case --- tests/index.test.tsx | 168 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 24608d4..155991a 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -1323,4 +1323,172 @@ describe('Tour', () => { expect(document.querySelector('.rc-tour')).toBeFalsy(); }); }); + + describe('keyboard navigation', () => { + it('should navigate steps with arrow keys', () => { + const onChange = jest.fn(); + render( + , + ); + + expect(document.querySelector('.rc-tour-title').innerHTML).toBe('step 2'); + + // Press ArrowLeft to go to previous step + fireEvent.keyDown(window, { key: 'ArrowLeft' }); + expect(onChange).toHaveBeenCalledWith(0); + expect(document.querySelector('.rc-tour-title').innerHTML).toBe('step 1'); + + // Press ArrowRight to go to next step + fireEvent.keyDown(window, { key: 'ArrowRight' }); + expect(onChange).toHaveBeenCalledWith(1); + expect(document.querySelector('.rc-tour-title').innerHTML).toBe('step 2'); + + // Press ArrowRight again + fireEvent.keyDown(window, { key: 'ArrowRight' }); + expect(onChange).toHaveBeenCalledWith(2); + expect(document.querySelector('.rc-tour-title').innerHTML).toBe('step 3'); + }); + + it('should not navigate beyond boundaries', () => { + const onChange = jest.fn(); + render( + , + ); + + expect(document.querySelector('.rc-tour-title').innerHTML).toBe('step 1'); + + // Press ArrowLeft at first step - should not change + fireEvent.keyDown(window, { key: 'ArrowLeft' }); + expect(onChange).not.toHaveBeenCalled(); + expect(document.querySelector('.rc-tour-title').innerHTML).toBe('step 1'); + + // Navigate to last step + fireEvent.keyDown(window, { key: 'ArrowRight' }); + expect(onChange).toHaveBeenCalledWith(1); + expect(document.querySelector('.rc-tour-title').innerHTML).toBe('step 2'); + + // Press ArrowRight at last step - should not change + onChange.mockClear(); + fireEvent.keyDown(window, { key: 'ArrowRight' }); + expect(onChange).not.toHaveBeenCalled(); + expect(document.querySelector('.rc-tour-title').innerHTML).toBe('step 2'); + }); + + it('should not navigate when keyboard is disabled', () => { + const onChange = jest.fn(); + render( + , + ); + + expect(document.querySelector('.rc-tour-title').innerHTML).toBe('step 2'); + + // Press arrow keys - should not navigate + fireEvent.keyDown(window, { key: 'ArrowLeft' }); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.keyDown(window, { key: 'ArrowRight' }); + expect(onChange).not.toHaveBeenCalled(); + + expect(document.querySelector('.rc-tour-title').innerHTML).toBe('step 2'); + }); + + it('should not navigate when keydown in editable elements', () => { + const onChange = jest.fn(); + const Demo = () => { + return ( +
+ +