Skip to content

Commit 25150c2

Browse files
committed
feat: integrate DialogAnchor into ContextMenu to keep stable position to reference element
1 parent 74a45a7 commit 25150c2

4 files changed

Lines changed: 117 additions & 36 deletions

File tree

src/components/Dialog/base/ContextMenu.tsx

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable @typescript-eslint/no-unused-vars */
12
import React, {
23
type ComponentProps,
34
type ComponentType,
@@ -10,6 +11,9 @@ import React, {
1011
} from 'react';
1112
import clsx from 'clsx';
1213
import { IconChevronLeft } from '../../Icons';
14+
import { useDialogIsOpen } from '../hooks';
15+
import type { DialogAnchorProps } from '../service/DialogAnchor';
16+
import { DialogAnchor } from '../service/DialogAnchor';
1317

1418
export const ContextMenuBackButton = ({
1519
children,
@@ -96,7 +100,7 @@ type ContextMenuLevel = {
96100
menuClassName?: string;
97101
};
98102

99-
export type ContextMenuProps = Omit<ComponentProps<'div'>, 'children'> & {
103+
type ContextMenuBaseProps = Omit<ComponentProps<'div'>, 'children'> & {
100104
backLabel?: ReactNode;
101105
items: ContextMenuItemComponent[];
102106
Header?: ContextMenuHeaderComponent;
@@ -106,7 +110,24 @@ export type ContextMenuProps = Omit<ComponentProps<'div'>, 'children'> & {
106110
onMenuLevelChange?: (level: number) => void;
107111
};
108112

109-
export const ContextMenu = ({
113+
/** When provided, ContextMenu renders inside DialogAnchor and wires menu level for submenu alignment. */
114+
type ContextMenuAnchorProps = Partial<
115+
Pick<
116+
DialogAnchorProps,
117+
| 'id'
118+
| 'dialogManagerId'
119+
| 'placement'
120+
| 'referenceElement'
121+
| 'tabIndex'
122+
| 'trapFocus'
123+
| 'allowFlip'
124+
| 'focus'
125+
>
126+
>;
127+
128+
export type ContextMenuProps = ContextMenuBaseProps & ContextMenuAnchorProps;
129+
130+
function ContextMenuContent({
110131
backLabel = 'Back',
111132
className,
112133
Header,
@@ -116,7 +137,7 @@ export const ContextMenu = ({
116137
onClose,
117138
onMenuLevelChange,
118139
...props
119-
}: ContextMenuProps) => {
140+
}: ContextMenuBaseProps) {
120141
const rootLevel = useMemo<ContextMenuLevel>(
121142
() => ({
122143
Header,
@@ -207,4 +228,65 @@ export const ContextMenu = ({
207228
</ContextMenuRoot>
208229
</ContextMenuContext.Provider>
209230
);
231+
}
232+
233+
export const ContextMenu = (props: ContextMenuProps) => {
234+
const {
235+
allowFlip,
236+
dialogManagerId,
237+
focus,
238+
id,
239+
placement,
240+
referenceElement,
241+
tabIndex,
242+
trapFocus,
243+
...menuProps
244+
} = props;
245+
246+
const isAnchored = id != null;
247+
248+
const [menuLevel, setMenuLevel] = useState(1);
249+
const open = useDialogIsOpen(id ?? '', dialogManagerId);
250+
251+
useEffect(() => {
252+
if (isAnchored && !open) setMenuLevel(1);
253+
}, [isAnchored, open]);
254+
255+
const content = (
256+
<ContextMenuContent
257+
{...menuProps}
258+
onMenuLevelChange={isAnchored ? setMenuLevel : menuProps.onMenuLevelChange}
259+
/>
260+
);
261+
262+
if (isAnchored) {
263+
const {
264+
backLabel: _b,
265+
Header: _h,
266+
items: _i,
267+
ItemsWrapper: _w,
268+
menuClassName: _m,
269+
onClose: _c,
270+
onMenuLevelChange: _l,
271+
...anchorDivProps
272+
} = menuProps;
273+
return (
274+
<DialogAnchor
275+
allowFlip={allowFlip}
276+
dialogManagerId={dialogManagerId}
277+
focus={focus}
278+
id={id}
279+
placement={placement}
280+
referenceElement={referenceElement}
281+
tabIndex={tabIndex}
282+
trapFocus={trapFocus}
283+
updateKey={menuLevel}
284+
{...anchorDivProps}
285+
>
286+
{content}
287+
</DialogAnchor>
288+
);
289+
}
290+
291+
return content;
210292
};

src/components/Dialog/service/DialogAnchor.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import clsx from 'clsx';
22
import type { ComponentProps, PropsWithChildren } from 'react';
3-
import React, { useEffect, useState } from 'react';
3+
import React, { useEffect, useRef, useState } from 'react';
44
import { FocusScope } from '@react-aria/focus';
55
import { DialogPortalEntry } from './DialogPortal';
66
import { useDialog, useDialogIsOpen } from '../hooks';
@@ -29,22 +29,33 @@ export function useDialogAnchor<T extends HTMLElement>({
2929
placement,
3030
});
3131

32+
// Freeze reference when dialog opens so submenus (e.g. ContextMenu level 2+) stay aligned to the original anchor
33+
const frozenReferenceRef = useRef<HTMLElement | null>(null);
34+
if (open && referenceElement && !frozenReferenceRef.current) {
35+
frozenReferenceRef.current = referenceElement;
36+
}
37+
if (!open) {
38+
frozenReferenceRef.current = null;
39+
}
40+
const effectiveReference = open ? frozenReferenceRef.current : referenceElement;
41+
3242
useEffect(() => {
33-
refs.setReference(referenceElement);
34-
}, [referenceElement, refs]);
43+
refs.setReference(effectiveReference);
44+
}, [effectiveReference, refs]);
3545

3646
useEffect(() => {
3747
refs.setFloating(popperElement);
3848
}, [popperElement, refs]);
3949

4050
useEffect(() => {
41-
if (open && popperElement) {
51+
if (open && popperElement && effectiveReference) {
52+
// Re-run when reference becomes available (e.g. after ref is set) or when updateKey changes (e.g. submenu open)
4253
// Since the popper's reference element might not be (and usually is not) visible
4354
// all the time, it's safer to force popper update before showing it.
4455
// update is non-null only if popperElement is non-null
4556
update?.();
4657
}
47-
}, [open, placement, popperElement, update, updateKey]);
58+
}, [open, placement, popperElement, update, updateKey, effectiveReference]);
4859

4960
if (popperElement && !open) {
5061
setPopperElement(null);
@@ -83,6 +94,7 @@ export const DialogAnchor = ({
8394
}: DialogAnchorProps) => {
8495
const dialog = useDialog({ dialogManagerId, id });
8596
const open = useDialogIsOpen(id, dialogManagerId);
97+
8698
const { setPopperElement, styles } = useDialogAnchor<HTMLDivElement>({
8799
allowFlip,
88100
open,

src/components/MessageActions/MessageActions.tsx

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
ContextMenu,
77
type ContextMenuItemComponent,
88
type ContextMenuItemProps,
9-
DialogAnchor,
109
useDialogIsOpen,
1110
useDialogOnNearestManager,
1211
} from '../Dialog';
@@ -117,25 +116,20 @@ export const MessageActions = ({
117116
<ActionsIcon className='str-chat__message-action-icon' />
118117
</Button>
119118

120-
<DialogAnchor
119+
<ContextMenu
120+
backLabel={t('Back')}
121+
className={clsx('str-chat__message-actions-box', {
122+
'str-chat__message-actions-box--open': dropdownDialogIsOpen,
123+
})}
121124
dialogManagerId={dialogManager?.id}
122125
id={dropdownDialogId}
126+
items={contextMenuItems}
127+
onClose={dialog?.close}
123128
placement={isMyMessage() ? 'top-end' : 'top-start'}
124129
referenceElement={actionsBoxButtonElement}
125130
tabIndex={-1}
126131
trapFocus
127-
>
128-
<ContextMenu
129-
backLabel={t('Back')}
130-
className={clsx(
131-
'str-chat__message-actions-box',
132-
{ 'str-chat__message-actions-box--open': dropdownDialogIsOpen },
133-
'str-chat__dialog-menu',
134-
)}
135-
items={contextMenuItems}
136-
onClose={dialog?.close}
137-
/>
138-
</DialogAnchor>
132+
/>
139133
</>
140134
)}
141135
{quickActionSet.map(({ Component: QuickActionComponent, type }) => (

src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
type ContextMenuItemProps,
1818
type ContextMenuOpenSubmenuParams,
1919
type ContextMenuSubmenu,
20-
DialogAnchor,
2120
useDialogIsOpen,
2221
useDialogOnNearestManager,
2322
} from '../../Dialog';
@@ -329,7 +328,6 @@ export const AttachmentSelector = ({
329328
const closeModal = useCallback(() => setModalContentActionAction(undefined), []);
330329

331330
const [fileInput, setFileInput] = useState<HTMLInputElement | null>(null);
332-
const [menuLevel, setMenuLevel] = useState(1);
333331
const menuButtonRef = useRef<HTMLButtonElement>(null);
334332

335333
const contextMenuItems = useMemo(
@@ -378,25 +376,20 @@ export const AttachmentSelector = ({
378376
onClick={() => menuDialog?.toggle()}
379377
ref={menuButtonRef}
380378
/>
381-
<DialogAnchor
379+
<ContextMenu
382380
allowFlip
381+
backLabel={t('Back')}
382+
className='str-chat__attachment-selector-actions-menu'
383+
data-testid='attachment-selector-actions-menu'
383384
dialogManagerId={dialogManager?.id}
384385
id={menuDialogId}
386+
items={contextMenuItems}
387+
onClose={menuDialog.close}
385388
placement='top-start'
386389
referenceElement={menuButtonRef.current}
387390
tabIndex={-1}
388391
trapFocus
389-
updateKey={menuLevel}
390-
>
391-
<ContextMenu
392-
backLabel={t('Back')}
393-
className='str-chat__attachment-selector-actions-menu str-chat__dialog-menu'
394-
data-testid='attachment-selector-actions-menu'
395-
items={contextMenuItems}
396-
onClose={menuDialog.close}
397-
onMenuLevelChange={setMenuLevel}
398-
/>
399-
</DialogAnchor>
392+
/>
400393
<Portal
401394
getPortalDestination={getModalPortalDestination ?? getDefaultPortalDestination}
402395
isOpen={modalIsOpen}

0 commit comments

Comments
 (0)