Skip to content

Commit 3aff587

Browse files
author
e.mukhametkhanov
committed
feat: fix close floating elements and modals when esc keydown
1 parent 07c915f commit 3aff587

9 files changed

Lines changed: 214 additions & 69 deletions

File tree

packages/vkui/src/components/CalendarTime/CalendarTime.tsx

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -152,20 +152,6 @@ export const CalendarTime = ({
152152
}
153153
};
154154

155-
const stopPropagationOfEscapeKeyboardEventWhenSelectIsOpen = React.useCallback(
156-
(event: React.KeyboardEvent, isOpen: boolean) => {
157-
if (isOpen && event.key === 'Escape') {
158-
event.stopPropagation();
159-
}
160-
},
161-
[],
162-
);
163-
164-
const onSelectInputKeyDown = (e: React.KeyboardEvent, isOpen: boolean) => {
165-
onPickerKeyDown(e);
166-
stopPropagationOfEscapeKeyboardEventWhenSelectIsOpen(e, isOpen);
167-
};
168-
169155
const renderDoneButton = () => {
170156
const ButtonComponent = DoneButton ?? Button;
171157
return (
@@ -191,7 +177,7 @@ export const CalendarTime = ({
191177
onChange={onChange}
192178
options={localHours}
193179
setTime={setHoursFn}
194-
onInputKeyDown={onSelectInputKeyDown}
180+
onInputKeyDown={onPickerKeyDown}
195181
inputRef={hoursInputRef}
196182
inputLabel={changeHoursLabel}
197183
inputTestId={hoursTestId}
@@ -203,7 +189,7 @@ export const CalendarTime = ({
203189
onChange={onChange}
204190
options={localMinutes}
205191
setTime={setMinutesFn}
206-
onInputKeyDown={onSelectInputKeyDown}
192+
onInputKeyDown={onPickerKeyDown}
207193
inputRef={minutesInputRef}
208194
inputLabel={changeMinutesLabel}
209195
inputTestId={minutesTestId}

packages/vkui/src/components/ChipsSelect/ChipsSelect.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as React from 'react';
44
import type { MouseEventHandler } from 'react';
55
import { classNames } from '@vkontakte/vkjs';
66
import { useExternRef } from '../../hooks/useExternRef';
7+
import { useGlobalEscKeyDown } from '../../hooks/useGlobalEscKeyDown';
78
import { useGlobalOnEventOutside } from '../../hooks/useGlobalOnClickOutside';
89
import { useMergeProps } from '../../hooks/useMergeProps';
910
import { Keys } from '../../lib/accessibility';
@@ -438,13 +439,13 @@ export const ChipsSelect = <Option extends ChipOption>({
438439
}
439440
break;
440441
}
441-
case Keys.ESCAPE:
442442
case Keys.TAB:
443443
if (opened) {
444444
setOpened(false);
445445
}
446446
}
447447
};
448+
useGlobalEscKeyDown(opened && !readOnly, () => setOpened(false));
448449

449450
React.useEffect(() => {
450451
if (focusedOptionIndex === null) {

packages/vkui/src/components/CustomSelect/hooks/useInputKeyboardController.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as React from 'react';
2+
import { useGlobalEscKeyDown } from '../../../hooks/useGlobalEscKeyDown';
23
import { Keys, pressedKey } from '../../../lib/accessibility';
34
import type { SelectProps } from '../CustomSelect';
45
import type { UseFocusedOptionControllerReturn } from './useFocusedOptionController';
@@ -58,9 +59,6 @@ export function useInputKeyboardController({
5859
open();
5960
}
6061
break;
61-
case Keys.ESCAPE:
62-
close();
63-
break;
6462
case Keys.BACKSPACE:
6563
case Keys.DELETE: {
6664
open();
@@ -77,9 +75,11 @@ export function useInputKeyboardController({
7775
break;
7876
}
7977
},
80-
[scrollBoxRef, opened, close, focusOption, open, resetFocusedOption, selectFocused],
78+
[scrollBoxRef, opened, focusOption, open, resetFocusedOption, selectFocused],
8179
);
8280

81+
useGlobalEscKeyDown(opened, () => close());
82+
8383
const handleInputKeydown = React.useCallback(
8484
(event: React.KeyboardEvent) => {
8585
onInputKeyDown?.(event, opened);

packages/vkui/src/components/DateInput/DateInput.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -451,9 +451,7 @@ export const DateInput = ({
451451
const showCalendarButton = !disableCalendar && (accessible || (!accessible && !value));
452452
const showClearButton = value && !readOnly;
453453

454-
useGlobalEscKeyDown(open && !disableCalendar, closeCalendar, {
455-
capture: false,
456-
});
454+
useGlobalEscKeyDown(open && !disableCalendar, closeCalendar);
457455

458456
return (
459457
<FormField

packages/vkui/src/components/DateRangeInput/DateRangeInput.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -425,9 +425,7 @@ export const DateRangeInput = ({
425425
const showCalendarButton = !disableCalendar && (accessible || (!accessible && !value));
426426
const showClearButton = value && !readOnly;
427427

428-
useGlobalEscKeyDown(open && !disableCalendar, closeCalendar, {
429-
capture: false,
430-
});
428+
useGlobalEscKeyDown(open && !disableCalendar, closeCalendar);
431429

432430
return (
433431
<FormField

packages/vkui/src/components/ModalCard/ModalCardInternal.tsx

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
'use client';
22
/* eslint-disable jsdoc/require-jsdoc */
33

4-
import { type ComponentType, type KeyboardEvent, type ReactNode, useCallback } from 'react';
4+
import { type ComponentType, type ReactNode } from 'react';
55
import { classNames, noop } from '@vkontakte/vkjs';
66
import { useAdaptivityWithJSMediaQueries } from '../../hooks/useAdaptivityWithJSMediaQueries';
77
import { useExternRef } from '../../hooks/useExternRef';
8+
import { useGlobalEscKeyDown } from '../../hooks/useGlobalEscKeyDown';
89
import { usePlatform } from '../../hooks/usePlatform';
910
import { useVirtualKeyboardState } from '../../hooks/useVirtualKeyboardState';
10-
import { Keys, pressedKey } from '../../lib/accessibility';
1111
import { useCSSTransition, type UseCSSTransitionState } from '../../lib/animation';
1212
import { useBottomSheet } from '../../lib/sheet';
1313
import { useScrollLock } from '../AppRoot/ScrollContext';
@@ -136,24 +136,13 @@ export const ModalCardInternal = ({
136136
}
137137
/>
138138
);
139-
const handleEscKeyDown = useCallback(
140-
(event: KeyboardEvent<HTMLElement>) => {
141-
if (closable && pressedKey(event) === Keys.ESCAPE) {
142-
onClose('escape-key');
143-
}
144-
},
145-
[closable, onClose],
146-
);
139+
140+
useGlobalEscKeyDown(closable, () => onClose('escape-key'));
147141

148142
useScrollLock(!hidden);
149143

150144
return (
151-
<ModalOutlet
152-
hidden={hidden}
153-
isDesktop={isDesktop}
154-
onKeyDown={handleEscKeyDown}
155-
disableModalOverlay={disableModalOverlay}
156-
>
145+
<ModalOutlet hidden={hidden} isDesktop={isDesktop} disableModalOverlay={disableModalOverlay}>
157146
{modalOverlay}
158147
<FocusTrap
159148
rootRef={handleRef}

packages/vkui/src/components/ModalPage/ModalPageInternal.tsx

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22
/* eslint-disable jsdoc/require-jsdoc */
33

4-
import { type ComponentType, type KeyboardEvent, useCallback } from 'react';
4+
import { type ComponentType } from 'react';
55
import { classNames, noop } from '@vkontakte/vkjs';
66
import { mergeStyle } from '../../helpers/mergeStyle';
77
import { useAdaptivity } from '../../hooks/useAdaptivity';
@@ -10,8 +10,8 @@ import {
1010
useAdaptivityWithJSMediaQueries,
1111
} from '../../hooks/useAdaptivityWithJSMediaQueries';
1212
import { useExternRef } from '../../hooks/useExternRef';
13+
import { useGlobalEscKeyDown } from '../../hooks/useGlobalEscKeyDown';
1314
import { useVirtualKeyboardState } from '../../hooks/useVirtualKeyboardState';
14-
import { Keys, pressedKey } from '../../lib/accessibility';
1515
import { useCSSTransition, type UseCSSTransitionState } from '../../lib/animation';
1616
import { type SnapPoint, type SnapPointChange, useBottomSheet } from '../../lib/sheet';
1717
import type { CSSCustomProperties } from '../../types';
@@ -150,24 +150,13 @@ export const ModalPageInternal = ({
150150
}
151151
/>
152152
);
153-
const handleEscKeyDown = useCallback(
154-
(event: KeyboardEvent<HTMLElement>) => {
155-
if (closable && pressedKey(event) === Keys.ESCAPE) {
156-
onClose('escape-key');
157-
}
158-
},
159-
[closable, onClose],
160-
);
153+
154+
useGlobalEscKeyDown(closable, () => onClose('escape-key'));
161155

162156
useScrollLock(!hidden);
163157

164158
return (
165-
<ModalOutlet
166-
hidden={hidden}
167-
isDesktop={isDesktop}
168-
onKeyDown={handleEscKeyDown}
169-
disableModalOverlay={disableModalOverlay}
170-
>
159+
<ModalOutlet hidden={hidden} isDesktop={isDesktop} disableModalOverlay={disableModalOverlay}>
171160
{modalOverlay}
172161
<FocusTrap
173162
rootRef={rootRef}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import * as React from 'react';
2+
import { fireEvent, render } from '@testing-library/react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import { isEscKeyHandled, useGlobalEscKeyDown } from './useGlobalEscKeyDown';
5+
6+
type EscHandlersFixtureProps = {
7+
onOuterClose: () => void;
8+
onInnerClose: () => void;
9+
};
10+
11+
const EscHandlersFixture = ({ onOuterClose, onInnerClose }: EscHandlersFixtureProps) => {
12+
const [outerOpened, setOuterOpened] = React.useState(true);
13+
const [innerOpened, setInnerOpened] = React.useState(true);
14+
15+
useGlobalEscKeyDown(outerOpened, () => {
16+
onOuterClose();
17+
setOuterOpened(false);
18+
});
19+
20+
useGlobalEscKeyDown(innerOpened, () => {
21+
onInnerClose();
22+
setInnerOpened(false);
23+
});
24+
25+
return <input data-testid="target" />;
26+
};
27+
28+
const SingleEscHandlerFixture = ({
29+
enabled,
30+
onEscKeyDown,
31+
}: {
32+
enabled: boolean;
33+
onEscKeyDown?: (event: KeyboardEvent) => void;
34+
}) => {
35+
useGlobalEscKeyDown(enabled, onEscKeyDown);
36+
return <input data-testid="target" />;
37+
};
38+
39+
describe(useGlobalEscKeyDown, () => {
40+
it('should close layers in stack order', () => {
41+
const onOuterClose = vi.fn();
42+
const onInnerClose = vi.fn();
43+
const { getByTestId } = render(
44+
<EscHandlersFixture onOuterClose={onOuterClose} onInnerClose={onInnerClose} />,
45+
);
46+
47+
const target = getByTestId('target');
48+
49+
fireEvent.keyDown(target, { key: 'Escape' });
50+
expect(onInnerClose).toHaveBeenCalledTimes(1);
51+
expect(onOuterClose).not.toHaveBeenCalled();
52+
53+
fireEvent.keyDown(target, { key: 'Escape' });
54+
expect(onOuterClose).toHaveBeenCalledTimes(1);
55+
});
56+
57+
it('should mark escape as handled', () => {
58+
const onOuterClose = vi.fn();
59+
const onInnerClose = vi.fn();
60+
const bubbleListener = vi.fn((event: KeyboardEvent) => isEscKeyHandled(event));
61+
const { getByTestId } = render(
62+
<EscHandlersFixture onOuterClose={onOuterClose} onInnerClose={onInnerClose} />,
63+
);
64+
65+
document.body.addEventListener('keydown', bubbleListener);
66+
67+
fireEvent.keyDown(getByTestId('target'), { key: 'Escape' });
68+
expect(bubbleListener).toHaveBeenCalledTimes(1);
69+
expect(bubbleListener).toHaveReturnedWith(true);
70+
71+
document.body.removeEventListener('keydown', bubbleListener);
72+
});
73+
74+
it('should not call callback for non-escape keys', () => {
75+
const onEscKeyDown = vi.fn();
76+
const { getByTestId } = render(<SingleEscHandlerFixture enabled onEscKeyDown={onEscKeyDown} />);
77+
78+
fireEvent.keyDown(getByTestId('target'), { key: 'Enter' });
79+
expect(onEscKeyDown).not.toHaveBeenCalled();
80+
});
81+
82+
it('should not subscribe when callback is not provided', () => {
83+
const { getByTestId } = render(<SingleEscHandlerFixture enabled={true} />);
84+
85+
expect(() => fireEvent.keyDown(getByTestId('target'), { key: 'Escape' })).not.toThrow();
86+
});
87+
88+
it('should re-subscribe when enabled state changes', () => {
89+
const onEscKeyDown = vi.fn();
90+
const { getByTestId, rerender } = render(
91+
<SingleEscHandlerFixture enabled={false} onEscKeyDown={onEscKeyDown} />,
92+
);
93+
94+
fireEvent.keyDown(getByTestId('target'), { key: 'Escape' });
95+
expect(onEscKeyDown).not.toHaveBeenCalled();
96+
97+
rerender(<SingleEscHandlerFixture enabled onEscKeyDown={onEscKeyDown} />);
98+
99+
fireEvent.keyDown(getByTestId('target'), { key: 'Escape' });
100+
expect(onEscKeyDown).toHaveBeenCalledTimes(1);
101+
});
102+
103+
it('should call latest callback after rerender', () => {
104+
const firstCallback = vi.fn();
105+
const secondCallback = vi.fn();
106+
const { getByTestId, rerender } = render(
107+
<SingleEscHandlerFixture enabled onEscKeyDown={firstCallback} />,
108+
);
109+
110+
rerender(<SingleEscHandlerFixture enabled onEscKeyDown={secondCallback} />);
111+
112+
fireEvent.keyDown(getByTestId('target'), { key: 'Escape' });
113+
expect(firstCallback).not.toHaveBeenCalled();
114+
expect(secondCallback).toHaveBeenCalledTimes(1);
115+
});
116+
});

0 commit comments

Comments
 (0)