Skip to content

Commit cc274ed

Browse files
authored
fix(pickers): close picker popup on Escape key press (#279)
1 parent cb1951c commit cc274ed

9 files changed

Lines changed: 252 additions & 2 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React from 'react';
2+
3+
import {Dialog} from '@gravity-ui/uikit';
4+
import {describe, expect, it} from 'vitest';
5+
import {userEvent} from 'vitest/browser';
6+
7+
import {render} from '#test-utils/utils';
8+
9+
import {DatePicker} from '../DatePicker';
10+
11+
function TestDatePicker() {
12+
const [open, setOpen] = React.useState(true);
13+
14+
return (
15+
<Dialog open={open} onClose={() => setOpen(false)}>
16+
<Dialog.Body>
17+
<div>Dialog content</div>
18+
<DatePicker disableFocusTrap />
19+
</Dialog.Body>
20+
</Dialog>
21+
);
22+
}
23+
24+
describe('DatePicker', () => {
25+
it('closes its popup on Escape from the group inside Dialog when focus trap is disabled', async () => {
26+
const screen = await render(<TestDatePicker />);
27+
28+
const combobox = screen.getByRole('combobox');
29+
const calendarButton = screen.getByRole('button', {name: 'Calendar'});
30+
combobox.element().focus();
31+
await userEvent.keyboard('{Alt>}{ArrowDown}{/Alt}');
32+
await expect(combobox).toHaveAttribute('aria-expanded', 'true');
33+
34+
calendarButton.element().focus();
35+
await userEvent.keyboard('{Escape}');
36+
37+
await expect(combobox).toHaveAttribute('aria-expanded', 'false');
38+
await expect(screen.getByText('Dialog content')).toBeInTheDocument();
39+
});
40+
});

src/components/DatePicker/hooks/useDatePickerProps.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,23 @@ export function useDatePickerProps<T extends DateTime | RangeValue<DateTime>>(
103103
style: props.style,
104104
'aria-disabled': state.disabled || undefined,
105105
onKeyDown: (e) => {
106-
if (!onlyTime && e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
106+
const isTargetInsideGroup = (e.currentTarget as HTMLElement).contains(
107+
e.target as Node,
108+
);
109+
110+
if (state.isOpen && e.key === 'Escape' && isTargetInsideGroup) {
111+
e.preventDefault();
112+
e.stopPropagation();
113+
state.setOpen(false, 'EscapeKeyDown');
114+
return;
115+
}
116+
117+
if (
118+
!onlyTime &&
119+
e.altKey &&
120+
(e.key === 'ArrowDown' || e.key === 'ArrowUp') &&
121+
isTargetInsideGroup
122+
) {
107123
e.preventDefault();
108124
e.stopPropagation();
109125
state.setOpen(true, 'ShortcutKeyDown');
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React from 'react';
2+
3+
import {Dialog} from '@gravity-ui/uikit';
4+
import {describe, expect, it} from 'vitest';
5+
import {userEvent} from 'vitest/browser';
6+
7+
import {render} from '#test-utils/utils';
8+
9+
import {RangeDatePicker} from '../RangeDatePicker';
10+
11+
function TestRangeDatePicker() {
12+
const [open, setOpen] = React.useState(true);
13+
14+
return (
15+
<Dialog open={open} onClose={() => setOpen(false)}>
16+
<Dialog.Body>
17+
<div>Dialog content</div>
18+
<RangeDatePicker disableFocusTrap />
19+
</Dialog.Body>
20+
</Dialog>
21+
);
22+
}
23+
24+
describe('RangeDatePicker', () => {
25+
it('closes its popup on Escape from the group inside Dialog when focus trap is disabled', async () => {
26+
const screen = await render(<TestRangeDatePicker />);
27+
28+
const combobox = screen.getByRole('combobox');
29+
const calendarButton = screen.getByRole('button', {name: 'Calendar'});
30+
combobox.element().focus();
31+
await userEvent.keyboard('{Alt>}{ArrowDown}{/Alt}');
32+
await expect(combobox).toHaveAttribute('aria-expanded', 'true');
33+
34+
calendarButton.element().focus();
35+
await userEvent.keyboard('{Escape}');
36+
37+
await expect(combobox).toHaveAttribute('aria-expanded', 'false');
38+
await expect(screen.getByText('Dialog content')).toBeInTheDocument();
39+
});
40+
});

src/components/RelativeDateField/__tests__/RelativeDateField.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
import React from 'react';
22

3+
import {Dialog} from '@gravity-ui/uikit';
34
import {afterEach, describe, expect, it, vitest} from 'vitest';
45
import {userEvent} from 'vitest/browser';
56

67
import {render} from '#test-utils/utils';
78

89
import {RelativeDateField} from '../RelativeDateField';
910

11+
function TestRelativeDateField() {
12+
const [open, setOpen] = React.useState(true);
13+
14+
return (
15+
<Dialog open={open} onClose={() => setOpen(false)}>
16+
<Dialog.Body>
17+
<div>Dialog content</div>
18+
<RelativeDateField />
19+
</Dialog.Body>
20+
</Dialog>
21+
);
22+
}
23+
1024
describe('RelativeDateField', () => {
1125
afterEach(() => {
1226
vitest.useRealTimers();
@@ -117,4 +131,21 @@ describe('RelativeDateField', () => {
117131
expect(hiddenInput).toHaveValue('now - 1d');
118132
expect(onUpdate).not.toHaveBeenCalled();
119133
});
134+
135+
it('closes popup on Escape from an element inside the group inside Dialog', async () => {
136+
const screen = await render(<TestRelativeDateField />);
137+
138+
const input = screen.getByRole('textbox').element();
139+
140+
input.focus();
141+
await userEvent.keyboard('{Alt>}{ArrowDown}{/Alt}');
142+
143+
expect(screen.getByRole('grid')).toBeInTheDocument();
144+
145+
input.focus();
146+
await userEvent.keyboard('{Escape}');
147+
148+
await expect.poll(() => screen.getByRole('grid')).not.toBeInTheDocument();
149+
await expect(screen.getByText('Dialog content')).toBeInTheDocument();
150+
});
120151
});

src/components/RelativeDateField/hooks/useRelativeDateFieldProps.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,17 @@ export function useRelativeDateFieldProps(
5757
...DOMProps,
5858
...focusWithinProps,
5959
role: 'group',
60+
onKeyDown: (e) => {
61+
if (
62+
isOpen &&
63+
e.key === 'Escape' &&
64+
(e.currentTarget as HTMLElement).contains(e.target as Node)
65+
) {
66+
e.preventDefault();
67+
e.stopPropagation();
68+
setOpen(false);
69+
}
70+
},
6071
},
6172
inputProps: {
6273
ref: setAnchor,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React from 'react';
2+
3+
import {Dialog} from '@gravity-ui/uikit';
4+
import {describe, expect, it} from 'vitest';
5+
import {userEvent} from 'vitest/browser';
6+
7+
import {render} from '#test-utils/utils';
8+
9+
import {RelativeDatePicker} from '../RelativeDatePicker';
10+
11+
function TestRelativeDatePicker() {
12+
const [open, setOpen] = React.useState(true);
13+
14+
return (
15+
<Dialog open={open} onClose={() => setOpen(false)}>
16+
<Dialog.Body>
17+
<div>Dialog content</div>
18+
<RelativeDatePicker />
19+
</Dialog.Body>
20+
</Dialog>
21+
);
22+
}
23+
24+
describe('RelativeDatePicker', () => {
25+
it('closes its popup on Escape from the group inside Dialog', async () => {
26+
const screen = await render(<TestRelativeDatePicker />);
27+
28+
const combobox = screen.getByRole('combobox');
29+
const calendarButton = screen.getByRole('button', {name: 'Calendar'});
30+
combobox.element().focus();
31+
await userEvent.keyboard('{Alt>}{ArrowDown}{/Alt}');
32+
await expect(combobox).toHaveAttribute('aria-expanded', 'true');
33+
34+
calendarButton.element().focus();
35+
await userEvent.keyboard('{Escape}');
36+
37+
await expect(combobox).toHaveAttribute('aria-expanded', 'false');
38+
await expect(screen.getByText('Dialog content')).toBeInTheDocument();
39+
});
40+
});

src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,23 @@ export function useRelativeDatePickerProps(
155155
role: 'group',
156156
...focusWithinProps,
157157
onKeyDown: (e) => {
158-
if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
158+
const isTargetInsideGroup = (e.currentTarget as HTMLElement).contains(
159+
e.target as Node,
160+
);
161+
162+
if (isOpen && e.key === 'Escape' && isTargetInsideGroup) {
163+
e.preventDefault();
164+
e.stopPropagation();
165+
setOpen(false);
166+
focusInput();
167+
return;
168+
}
169+
170+
if (
171+
e.altKey &&
172+
(e.key === 'ArrowDown' || e.key === 'ArrowUp') &&
173+
isTargetInsideGroup
174+
) {
159175
e.preventDefault();
160176
e.stopPropagation();
161177
setOpen(true);

src/components/RelativeRangeDatePicker/RelativeRangeDatePicker.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,23 @@ export function RelativeRangeDatePicker(props: RelativeRangeDatePickerProps) {
4848
delete DOMProps.id;
4949

5050
return (
51+
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
5152
<div
5253
{...DOMProps}
5354
ref={setAnchor}
5455
{...focusWithinProps}
56+
onKeyDown={(e) => {
57+
if (
58+
open &&
59+
e.key === 'Escape' &&
60+
(e.currentTarget as HTMLElement).contains(e.target as Node)
61+
) {
62+
e.preventDefault();
63+
e.stopPropagation();
64+
setOpen(false);
65+
dialogClosing.current = true;
66+
}
67+
}}
5568
className={b(null, props.className)}
5669
style={props.style}
5770
>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import React from 'react';
2+
3+
import {Dialog} from '@gravity-ui/uikit';
4+
import {describe, expect, it} from 'vitest';
5+
import {userEvent} from 'vitest/browser';
6+
7+
import {render} from '#test-utils/utils';
8+
9+
import {RelativeRangeDatePicker} from '../RelativeRangeDatePicker';
10+
11+
function TestRelativeRangeDatePicker() {
12+
const [open, setOpen] = React.useState(true);
13+
14+
return (
15+
<Dialog open={open} onClose={() => setOpen(false)}>
16+
<Dialog.Body>
17+
<div>Dialog content</div>
18+
<RelativeRangeDatePicker label="Range" />
19+
</Dialog.Body>
20+
</Dialog>
21+
);
22+
}
23+
24+
describe('RelativeRangeDatePicker', () => {
25+
it('closes its popup on Escape from the group inside Dialog', async () => {
26+
const screen = await render(<TestRelativeRangeDatePicker />);
27+
28+
const combobox = screen.getByLabelText('Range', {exact: true});
29+
const calendarButton = screen.getByLabelText('Range date picker');
30+
31+
expect(combobox).toBeInTheDocument();
32+
expect(calendarButton).toBeInTheDocument();
33+
34+
combobox.element().focus();
35+
await userEvent.keyboard('{Alt>}{ArrowDown}{/Alt}');
36+
expect(combobox).toHaveAttribute('aria-expanded', 'true');
37+
combobox.element().focus();
38+
await userEvent.keyboard('{Escape}');
39+
40+
expect(combobox).toHaveAttribute('aria-expanded', 'false');
41+
expect(screen.getByText('Dialog content')).toBeInTheDocument();
42+
});
43+
});

0 commit comments

Comments
 (0)