Skip to content

Commit cef7fa5

Browse files
authored
feat: add matchReferenceWidth to DropdownProps (#3166)
1 parent 0c0d65c commit cef7fa5

3 files changed

Lines changed: 101 additions & 2 deletions

File tree

src/components/Dialog/__tests__/DialogPortal.test.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import React from 'react';
22
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import {
4+
Dropdown,
5+
type DropdownTriggerProps,
6+
useDropdownContext,
7+
} from '../../Form/Dropdown';
38
import { useDialogOnNearestManager } from '../hooks';
49
import { DialogAnchor } from '../service';
510
import { DialogManagerProvider } from '../../../context';
@@ -29,6 +34,32 @@ const DialogFixture = ({
2934
);
3035
};
3136

37+
const DropdownTriggerButton = ({
38+
children,
39+
onClick,
40+
referenceRef,
41+
...props
42+
}: DropdownTriggerProps) => (
43+
<button
44+
{...props}
45+
onClick={onClick}
46+
ref={referenceRef as React.Ref<HTMLButtonElement>}
47+
type='button'
48+
>
49+
{children}
50+
</button>
51+
);
52+
53+
const DropdownItem = ({ label }: { label: string }) => {
54+
const { close } = useDropdownContext();
55+
56+
return (
57+
<button onClick={close} role='menuitem' type='button'>
58+
{label}
59+
</button>
60+
);
61+
};
62+
3263
describe('DialogPortal', () => {
3364
it('does not close dialogs from another manager when clicking in a different manager overlay', async () => {
3465
render(
@@ -118,4 +149,47 @@ describe('DialogPortal', () => {
118149
const results = await axe(document.body);
119150
expect(results).toHaveNoViolations();
120151
});
152+
153+
it('does not close the dialog when Escape is handled by a nested dropdown', async () => {
154+
render(
155+
<DialogManagerProvider>
156+
<DialogFixture
157+
dialogId='dialog-with-dropdown'
158+
testId='dialog-dropdown-content'
159+
trapFocus
160+
>
161+
<Dropdown
162+
TriggerComponent={DropdownTriggerButton}
163+
triggerProps={{ children: 'Duration' }}
164+
>
165+
<DropdownItem label='15 minutes' />
166+
<DropdownItem label='1 hour' />
167+
</Dropdown>
168+
</DialogFixture>
169+
</DialogManagerProvider>,
170+
);
171+
172+
fireEvent.click(screen.getByTestId('open-dialog-with-dropdown'));
173+
expect(screen.getByRole('dialog')).toBeInTheDocument();
174+
175+
fireEvent.click(screen.getByRole('button', { name: 'Duration' }));
176+
await screen.findByRole('menu');
177+
178+
const item = screen.getByRole('menuitem', { name: '15 minutes' });
179+
item.focus();
180+
181+
fireEvent.keyDown(item, { key: 'Escape' });
182+
fireEvent.keyUp(item, { key: 'Escape' });
183+
184+
await waitFor(() => {
185+
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
186+
});
187+
expect(screen.getByRole('dialog')).toBeInTheDocument();
188+
189+
fireEvent.keyUp(document, { key: 'Escape' });
190+
191+
await waitFor(() => {
192+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
193+
});
194+
});
121195
});

src/components/Dialog/service/DialogAnchor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export const DialogAnchor = ({
212212
useEffect(() => {
213213
if (!open) return;
214214
const hideOnEscape = (event: KeyboardEvent) => {
215-
if (event.key !== 'Escape') return;
215+
if (event.key !== 'Escape' || event.defaultPrevented) return;
216216
dialog?.close();
217217
};
218218

src/components/Form/Dropdown.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
ReactNode,
88
Ref,
99
} from 'react';
10-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
10+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1111

1212
import { createRovingFocusKeyDownHandler } from '../../a11y/a11yUtils';
1313
import {
@@ -18,6 +18,11 @@ import {
1818
const DEFAULT_DROPDOWN_ITEM_SELECTOR =
1919
'[role="option"]:not(:disabled), [role="menuitem"]:not(:disabled), button:not(:disabled), a:not(:disabled)';
2020

21+
const isEditableTarget = (target: EventTarget | null) =>
22+
target instanceof HTMLInputElement ||
23+
target instanceof HTMLTextAreaElement ||
24+
(target instanceof HTMLElement && target.isContentEditable);
25+
2126
type DropdownContextValue = {
2227
close(): void;
2328
};
@@ -47,6 +52,7 @@ export type DropdownTriggerProps = Pick<
4752

4853
export type DropdownProps = PropsWithChildren<{
4954
className?: string;
55+
matchReferenceWidth?: boolean;
5056
onClose?: () => void;
5157
onOpen?: () => void;
5258
placement?: PopperLikePlacement;
@@ -58,6 +64,7 @@ export type DropdownProps = PropsWithChildren<{
5864
export const Dropdown = ({
5965
children,
6066
className,
67+
matchReferenceWidth = false,
6168
onClose,
6269
onOpen,
6370
placement = 'bottom',
@@ -142,18 +149,32 @@ export const Dropdown = ({
142149
[],
143150
);
144151

152+
const escapeConsumedRef = useRef(false);
153+
145154
const handleKeyDown = useCallback(
146155
(event: React.KeyboardEvent<HTMLElement>) => {
147156
if (event.key === 'Escape') {
148157
event.preventDefault();
158+
event.stopPropagation();
159+
escapeConsumedRef.current = true;
149160
close();
150161
return;
151162
}
163+
if (isEditableTarget(event.target)) {
164+
return;
165+
}
152166
rovingFocusKeyDownHandler(event);
153167
},
154168
[close, rovingFocusKeyDownHandler],
155169
);
156170

171+
const suppressEscapeKeyUp = useCallback((event: React.KeyboardEvent<HTMLElement>) => {
172+
if (event.key === 'Escape' && escapeConsumedRef.current) {
173+
escapeConsumedRef.current = false;
174+
event.stopPropagation();
175+
}
176+
}, []);
177+
157178
const DropdownTriggerComponent = TriggerComponent;
158179

159180
const trigger = DropdownTriggerComponent ? (
@@ -173,10 +194,14 @@ export const Dropdown = ({
173194
className={clsx('str-chat__dropdown__items', className)}
174195
onClick={close}
175196
onKeyDown={handleKeyDown}
197+
onKeyUpCapture={suppressEscapeKeyUp}
176198
ref={setFloatingElement}
177199
role='menu'
178200
style={{
179201
left: x ?? 0,
202+
minWidth: matchReferenceWidth
203+
? resolvedReferenceElement.getBoundingClientRect().width
204+
: undefined,
180205
position: strategy,
181206
top: y ?? 0,
182207
}}

0 commit comments

Comments
 (0)