diff --git a/package.json b/package.json index 3fe901c..fd6a250 100644 --- a/package.json +++ b/package.json @@ -40,9 +40,9 @@ "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.3.0", + "@rc-component/util": "^1.7.0", "clsx": "^2.1.1" }, "devDependencies": { 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 => { 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,52 @@ const Tour: React.FC = props => { } return getPlacements(arrowPointAtCenter); }, [builtinPlacements, arrowPointAtCenter]); + const handleClose = () => { + setMergedOpen(false); + 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 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)) { + return; + } + + if (keyboard && e.key === 'ArrowLeft') { + if (mergedCurrent > 0) { + e.preventDefault(); + onInternalChange(mergedCurrent - 1); + } + return; + } + + if (keyboard && e.key === 'ArrowRight') { + if (mergedCurrent < steps.length - 1) { + e.preventDefault(); + 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 +211,6 @@ const Tour: React.FC = props => { return null; } - const handleClose = () => { - setMergedOpen(false); - onClose?.(mergedCurrent); - }; - const getPopupElement = () => ( = props => { animated={animated} rootClassName={rootClassName} disabledInteraction={disabledInteraction} + onEsc={handleEscClose} /> { style?: React.CSSProperties; steps?: TourStepInfo[]; open?: boolean; + keyboard?: boolean; defaultOpen?: boolean; defaultCurrent?: number; current?: number; diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 6f27163..155991a 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -1289,4 +1289,206 @@ 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(); + }); + }); + + 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 ( +
+ +