Skip to content

Commit 7c6a7eb

Browse files
eirikbackerCopilotmimarz
authored
fix(Popover): better handle esc when dialog is open (#4788)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Michael Marszalek <mimarz@gmail.com>
1 parent 82954a4 commit 7c6a7eb

4 files changed

Lines changed: 121 additions & 12 deletions

File tree

.changeset/strange-yaks-burn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@digdir/designsystemet-react": patch
3+
---
4+
5+
**Popover:** No longer closes with "Escape" key if a Dialog element is open above the popover

packages/react/src/components/dropdown/dropdown.stories.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,51 @@ WithNestedDialog.play = async (ctx) => {
221221
await expect(dialog).toBeInTheDocument();
222222
await waitFor(() => expect(dialog).toBeVisible());
223223
};
224+
225+
export const WithAdjacentDialog: StoryFn<typeof Dropdown> = (args) => {
226+
return (
227+
<>
228+
<Dropdown.TriggerContext>
229+
<Dropdown.Trigger data-color={args['data-color']}>
230+
Dropdown
231+
</Dropdown.Trigger>
232+
<Dropdown {...args}>
233+
<Dropdown.List>
234+
<Dropdown.Item>
235+
<Dropdown.Button command='show-modal' commandFor='adjacent-modal'>
236+
Dialog
237+
</Dropdown.Button>
238+
</Dropdown.Item>
239+
</Dropdown.List>
240+
</Dropdown>
241+
</Dropdown.TriggerContext>
242+
<Dialog id='adjacent-modal'>Min dialog</Dialog>
243+
</>
244+
);
245+
};
246+
247+
export const WithNestedDropdown: StoryFn<typeof Dropdown> = (args) => {
248+
return (
249+
<Dropdown.TriggerContext>
250+
<Dropdown.Trigger data-color={args['data-color']}>
251+
Dropdown
252+
</Dropdown.Trigger>
253+
<Dropdown {...args}>
254+
<Dropdown.List>
255+
<Dropdown.Item>
256+
<Dropdown.TriggerContext>
257+
<Dropdown.Trigger variant='tertiary'>Dropdown</Dropdown.Trigger>
258+
<Dropdown>
259+
<Dropdown.List>
260+
<Dropdown.Item>
261+
<Dropdown.Button>Nested</Dropdown.Button>
262+
</Dropdown.Item>
263+
</Dropdown.List>
264+
</Dropdown>
265+
</Dropdown.TriggerContext>
266+
</Dropdown.Item>
267+
</Dropdown.List>
268+
</Dropdown>
269+
</Dropdown.TriggerContext>
270+
);
271+
};

packages/react/src/components/popover/popover.test.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { act, render, screen } from '@testing-library/react';
33
import { useState } from 'react';
44

55
import { Button } from '../';
6+
import { Dialog } from '../dialog';
67
import { Popover } from './';
78

89
const CompControlled = () => {
@@ -69,7 +70,7 @@ describe('Popover', () => {
6970
expect(screen.getByText(contentText)).toBeVisible();
7071
});
7172

72-
it('should close when we click the button twitce', async () => {
73+
it('should close when we click the button twice', async () => {
7374
render(defaultPopover);
7475
const popoverTrigger = screen.getByRole('button');
7576

@@ -104,6 +105,49 @@ describe('Popover', () => {
104105
expect(screen.getByText(contentText)).not.toBeVisible();
105106
});
106107

108+
it('should not close on ESC when a Dialog is open above the popover', async () => {
109+
const onClose = vi.fn();
110+
render(
111+
<>
112+
<Button popovertarget='popover'>popover trigger</Button>
113+
<Popover id='popover' onClose={onClose}>
114+
{contentText}
115+
<Button command='show-modal' commandfor='dialog'>
116+
dialog trigger
117+
</Button>
118+
</Popover>
119+
<Dialog
120+
id='dialog'
121+
closeButton={false}
122+
style={{ inset: 0, width: '100vw', height: '100vh', margin: 0 }}
123+
aria-label='covering dialog'
124+
>
125+
dialog content
126+
</Dialog>
127+
</>,
128+
);
129+
130+
const popoverTrigger = screen.getByRole('button');
131+
await act(async () => popoverTrigger.click());
132+
133+
const dialogTrigger = screen.getByRole('button', {
134+
name: 'dialog trigger',
135+
});
136+
await act(async () => dialogTrigger.click());
137+
138+
expect(screen.getByText(contentText)).toBeVisible();
139+
expect(screen.getByRole('dialog')).toBeVisible();
140+
141+
const esc = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
142+
await act(async () => document.body.dispatchEvent(esc));
143+
144+
expect(onClose).not.toHaveBeenCalled();
145+
expect(screen.getByText(contentText)).toBeVisible();
146+
147+
expect(screen.queryByRole('dialog')).toBeNull();
148+
expect(screen.getByText('dialog content')).not.toBeVisible();
149+
});
150+
107151
it('should close when we press SPACE', async () => {
108152
render(defaultPopover);
109153

packages/react/src/components/popover/popover.tsx

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ export type PopoverProps = MergeRight<
7474
* Content
7575
* </Popover>
7676
*/
77-
7877
export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
7978
function Popover(
8079
{
@@ -118,16 +117,12 @@ export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
118117
};
119118

120119
const handleKeydown = (event: KeyboardEvent) => {
121-
if (event.key !== 'Escape' || !controlledOpen) return;
122-
const isOpen =
123-
popoverRef.current?.matches(':popover-open') ||
124-
popoverRef.current?.classList.contains(':popover-open'); // Polyfill support
125-
126-
if (!isOpen) return;
127-
event.preventDefault(); // Prevent closing fullscreen in Safari
128-
document.querySelector<HTMLElement>(trigger)?.focus?.(); // Move focus back to trigger since `popoover="manual"` doesn't do this
129-
setInternalOpen(false);
130-
onClose?.();
120+
if (event.key === 'Escape' && controlledOpen && isTopLayer(popover)) {
121+
event.preventDefault(); // Prevent closing fullscreen in Safari
122+
document.querySelector<HTMLElement>(trigger)?.focus?.(); // Move focus back to trigger since `popover="manual"` doesn't do this
123+
setInternalOpen(false);
124+
onClose?.();
125+
}
131126
};
132127

133128
popover?.togglePopover?.(controlledOpen);
@@ -163,3 +158,20 @@ export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
163158
);
164159
},
165160
);
161+
162+
// NOTE: This is not able to check if the popover is the most recently added #topLayer,
163+
// so we need another method in time, or remove the controlled popover="manual" in a v2
164+
const isTopLayer = (checkElement?: Element | null) => {
165+
if (!checkElement) return false;
166+
const { x, y, width, height } = checkElement.getBoundingClientRect();
167+
const topElement = document.elementFromPoint(x + width / 2, y + height / 2);
168+
169+
// If the element on top is on browser #top-layer but not same as provided element, then provided element is not on top
170+
for (let el = topElement; el; el = el.parentElement) {
171+
if (checkElement === el) return true; // If the topElement is same as provided element, it's on top
172+
if (el instanceof HTMLDialogElement && el.open) return false; // Check for open dialog
173+
if (el.classList.contains(':popover-open')) return false; // Polyfill support
174+
if (el.matches(':popover-open')) return false; // Native support
175+
}
176+
return false;
177+
};

0 commit comments

Comments
 (0)