Skip to content

Commit c07963d

Browse files
committed
fix(useContextMenu): api
1 parent 70b2d5a commit c07963d

File tree

3 files changed

+133
-29
lines changed

3 files changed

+133
-29
lines changed

src/components/actions/use-context-menu.docs.mdx

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Meta } from '@storybook/blocks';
66

77
## Purpose
88

9-
`useContextMenu` is a React hook that enables components to display context menus (like `Menu` or `CommandMenu`) at the exact cursor position where the user right-clicked or triggered a context action. Unlike [`useAnchoredMenu`](./use-anchored-menu.docs.mdx) which anchors menus to specific elements, `useContextMenu` positions menus at cursor coordinates, making it perfect for traditional right-click context menus.
9+
`useContextMenu` is a React hook that enables components to display context menus (like `Menu` or `CommandMenu`) at the exact cursor position where the user right-clicked or triggered a context action. Unlike [`useAnchoredMenu`](./use-anchored-menu.docs.mdx) which anchors menus to specific elements, `useContextMenu` positions menus at cursor coordinates or centers them on the target element, making it perfect for traditional right-click context menus and programmatic menu opening.
1010

1111
The hook automatically handles `onContextMenu` event binding and provides a clean API for both automatic (right-click) and programmatic menu opening.
1212

@@ -32,17 +32,17 @@ function useContextMenu<P, T = ComponentProps<typeof MenuTrigger>>(
3232
targetRef: RefObject<HTMLElement>;
3333

3434
/**
35-
* Programmatically opens the menu at the specified event coordinates.
35+
* Programmatically opens the menu at the specified coordinates or element center.
3636
* Runtime props are merged with defaultMenuProps (runtime props take precedence).
3737
*
38-
* @param event - The pointer/mouse event containing coordinates for positioning
3938
* @param props - Props to pass to the menu component (optional, defaults to defaultMenuProps)
4039
* @param triggerProps - Additional props for MenuTrigger (merged with defaultTriggerProps)
40+
* @param event - The pointer/mouse event containing coordinates for positioning (optional, centers on element if not provided)
4141
*/
4242
open(
43-
event: MouseEvent | PointerEvent | React.MouseEvent | React.PointerEvent,
4443
props?: P,
4544
triggerProps?: T,
45+
event?: MouseEvent | PointerEvent,
4646
): void;
4747

4848
/**
@@ -82,14 +82,20 @@ The hook automatically binds `onContextMenu` events to the `targetRef` element:
8282
- **`event.preventDefault()`** is called automatically to suppress the browser's native context menu
8383
- **No manual event binding required** in most cases
8484

85-
### Coordinate-Based Positioning
85+
### Flexible Positioning
8686

87-
Unlike element-anchored menus, `useContextMenu` positions menus at exact cursor coordinates:
87+
`useContextMenu` supports two positioning modes:
8888

89-
- Creates an **invisible anchor element** at click coordinates
90-
- Supports **collision detection** and automatic repositioning
91-
- **Clamps coordinates** to stay within the container bounds
92-
- Works with **scrollable containers** by accounting for scroll offset
89+
1. **Coordinate-Based Positioning** (when event is provided):
90+
- Creates an **invisible anchor element** at click coordinates
91+
- Supports **collision detection** and automatic repositioning
92+
- **Clamps coordinates** to stay within the container bounds
93+
- Works with **scrollable containers** by accounting for scroll offset
94+
95+
2. **Center Positioning** (when no event is provided):
96+
- Positions the menu at the **center of the target element**
97+
- Useful for **programmatic menu opening** from buttons or keyboard shortcuts
98+
- Respects **element padding and borders** for accurate centering
9399

94100
### Props Merging Strategy
95101

@@ -148,31 +154,38 @@ function FileItem({ file }) {
148154

149155
| Feature | `useContextMenu` | `useAnchoredMenu` |
150156
|---------|------------------|-------------------|
151-
| **Positioning** | Cursor coordinates | Element-anchored |
152-
| **Trigger** | Right-click (automatic) | Manual (button click) |
157+
| **Positioning** | Cursor coordinates or element center | Element-anchored |
158+
| **Trigger** | Right-click (automatic) + programmatic | Manual (button click) |
153159
| **Event Binding** | Automatic `onContextMenu` | Manual event handling |
154-
| **Use Cases** | Context menus, right-click actions | Dropdown menus, action buttons |
160+
| **Use Cases** | Context menus, right-click actions, toolbar dropdowns | Dropdown menus, action buttons |
155161
| **Default Placement** | `"bottom start"` | `"bottom start"` |
156162
| **Setup Complexity** | Minimal (auto-binding) | Manual event handling |
157163

158164
## Implementation Notes
159165

160166
### Coordinate Calculation
161167

162-
The hook calculates menu position by:
168+
The hook calculates menu position differently based on whether an event is provided:
163169

170+
**With Event (Coordinate-Based):**
164171
1. **Getting viewport coordinates** from the pointer event
165172
2. **Converting to container-relative coordinates** accounting for borders and scroll
166173
3. **Clamping to container bounds** to ensure the menu stays visible
167174
4. **Creating an invisible anchor** at the calculated position
168175
5. **Delegating to MenuTrigger** for collision detection and final positioning
169176

177+
**Without Event (Center-Based):**
178+
1. **Calculating element center** using `clientWidth/2` and `clientHeight/2`
179+
2. **Accounting for scroll offset** to position relative to content area
180+
3. **Clamping to container bounds** to ensure proper positioning
181+
4. **Creating an invisible anchor** at the center position
182+
170183
### Event Handling
171184

172185
- **Automatic binding** occurs via `useEffect` on the `targetRef`
173186
- **Event listeners** are properly cleaned up on unmount
174187
- **`preventDefault()`** is called automatically for context menu events
175-
- **Manual `open()` calls** can still prevent default if needed
188+
- **Manual `open()` calls** can still prevent default if an event is provided
176189

177190
### Memory Management
178191

src/components/actions/use-context-menu.test.tsx

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,17 @@ describe('useContextMenu', () => {
7979
);
8080

8181
return (
82-
<div ref={targetRef} data-qa="container">
82+
<div
83+
ref={targetRef as React.RefObject<HTMLDivElement>}
84+
data-qa="container"
85+
>
8386
<button
8487
data-qa="trigger"
8588
onClick={(e) => {
8689
if (onTriggerClick) {
8790
onTriggerClick();
8891
}
89-
open(e, componentProps, triggerProps);
92+
open(componentProps, triggerProps, e);
9093
}}
9194
>
9295
Open Menu
@@ -435,11 +438,11 @@ describe('useContextMenu', () => {
435438
useContextMenu<HTMLDivElement>(TestMenuComponent);
436439

437440
const handleClick1 = (e: React.MouseEvent) => {
438-
open(e, { onAction: onAction1, sadf: '123' });
441+
open({ onAction: onAction1, sadf: '123' }, undefined, e);
439442
};
440443

441444
const handleClick2 = (e: React.MouseEvent) => {
442-
open(e, { onAction: onAction2 });
445+
open({ onAction: onAction2 }, undefined, e);
443446
};
444447

445448
return (
@@ -597,7 +600,7 @@ describe('useContextMenu', () => {
597600
} as any;
598601

599602
expect(() => {
600-
result.current.open(mockEvent, {});
603+
result.current.open({}, undefined, mockEvent);
601604
}).toThrow(
602605
'useContextMenu: MenuTrigger must be rendered. Use `rendered` property to include it in your component tree.',
603606
);
@@ -680,7 +683,7 @@ describe('useContextMenu', () => {
680683

681684
return (
682685
<div ref={targetRef} data-qa="container">
683-
<button data-qa="trigger" onClick={(e) => open(e, {})}>
686+
<button data-qa="trigger" onClick={(e) => open({}, undefined, e)}>
684687
{isOpen ? 'Close Menu' : 'Open Menu'}
685688
</button>
686689
<button data-qa="close-button" onClick={close}>
@@ -835,7 +838,7 @@ describe('useContextMenu', () => {
835838
);
836839

837840
const handleManualOpen = (e: React.MouseEvent) => {
838-
open(e, { onAction: runtimeAction, width: '200px' }); // Should override default onAction
841+
open({ onAction: runtimeAction, width: '200px' }, undefined, e); // Should override default onAction
839842
};
840843

841844
return (
@@ -883,4 +886,66 @@ describe('useContextMenu', () => {
883886
// Should use default action
884887
expect(defaultAction).toHaveBeenCalledWith('edit');
885888
});
889+
890+
it('should position menu at element center when no event is provided', async () => {
891+
const onAction = jest.fn();
892+
893+
const TestCenterWrapper = () => {
894+
const { targetRef, open, rendered } = useContextMenu<HTMLDivElement>(
895+
TestMenuComponent,
896+
{ placement: 'bottom start' },
897+
{ onAction },
898+
);
899+
900+
const handleCenterOpen = () => {
901+
open(); // No event provided - should center on element
902+
};
903+
904+
return (
905+
<div
906+
ref={targetRef}
907+
data-qa="container"
908+
style={{ width: 200, height: 100, padding: '20px' }}
909+
>
910+
<button data-qa="center-trigger" onClick={handleCenterOpen}>
911+
Open Centered
912+
</button>
913+
{rendered}
914+
</div>
915+
);
916+
};
917+
918+
const { getByTestId, getByRole, getByText } = renderWithRoot(
919+
<TestCenterWrapper />,
920+
);
921+
922+
const centerTrigger = getByTestId('center-trigger');
923+
924+
// Open without event (should center on element)
925+
await act(async () => {
926+
await userEvent.click(centerTrigger);
927+
});
928+
929+
// Menu should be visible
930+
await waitFor(() => {
931+
expect(getByRole('menu')).toBeInTheDocument();
932+
});
933+
934+
expect(getByText('Edit')).toBeInTheDocument();
935+
936+
// Verify that invisible anchor is positioned (we can't easily verify exact center coordinates in tests)
937+
const container = getByTestId('container');
938+
const invisibleAnchor = container.querySelector(
939+
'span[style*="position: absolute"]',
940+
);
941+
expect(invisibleAnchor).toBeInTheDocument();
942+
943+
// Click on an item to test action
944+
const editItem = getByText('Edit');
945+
await act(async () => {
946+
await userEvent.click(editItem);
947+
});
948+
949+
expect(onAction).toHaveBeenCalledWith('edit');
950+
});
886951
});

src/components/actions/use-context-menu.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,17 @@ export interface UseContextMenuReturn<
3232
targetRef: RefObject<E>;
3333

3434
/**
35-
* Programmatically opens the menu at the specified event coordinates.
35+
* Programmatically opens the menu at the specified coordinates or element center.
3636
* Runtime props are merged with defaultMenuProps (runtime props take precedence).
3737
*
38-
* @param event - The pointer/mouse event containing coordinates for positioning
3938
* @param props - Props to pass to the menu component (optional, defaults to defaultMenuProps)
4039
* @param triggerProps - Additional props for MenuTrigger (merged with defaultTriggerProps)
40+
* @param event - The pointer/mouse event containing coordinates for positioning (optional, centers on element if not provided)
4141
*/
4242
open(
43-
event: NativeMouseEvent | NativePointerEvent | MouseEvent | PointerEvent,
4443
props?: P,
4544
triggerProps?: T,
45+
event?: NativeMouseEvent | NativePointerEvent | MouseEvent | PointerEvent,
4646
): void;
4747

4848
/**
@@ -134,10 +134,35 @@ export function useContextMenu<
134134
// element's scroll offset into account. Without the scroll offset the menu
135135
// would be rendered at the wrong place inside scrollable containers.
136136
const calculatePosition = (
137-
event: NativeMouseEvent | NativePointerEvent | MouseEvent | PointerEvent,
137+
event?: NativeMouseEvent | NativePointerEvent | MouseEvent | PointerEvent,
138138
) => {
139139
const container = targetRef.current;
140140

141+
// If no event is provided, position at the center of the element
142+
if (!event) {
143+
if (!container) {
144+
return { x: 0, y: 0 };
145+
}
146+
147+
const containerRect = container.getBoundingClientRect();
148+
const scrollLeft = container.scrollLeft;
149+
const scrollTop = container.scrollTop;
150+
151+
const computed = window.getComputedStyle(container);
152+
const borderLeft = parseFloat(computed.borderLeftWidth) || 0;
153+
const borderTop = parseFloat(computed.borderTopWidth) || 0;
154+
155+
// Position at the center of the element's content area
156+
const x = container.clientWidth / 2 + scrollLeft;
157+
const y = container.clientHeight / 2 + scrollTop;
158+
159+
// Clamp to the full scroll size
160+
const clampedX = Math.max(0, Math.min(x, container.scrollWidth));
161+
const clampedY = Math.max(0, Math.min(y, container.scrollHeight));
162+
163+
return { x: clampedX, y: clampedY };
164+
}
165+
141166
// If the target reference is missing, fall back to viewport coordinates.
142167
if (!container) {
143168
const { clientX = 0, clientY = 0 } = event;
@@ -171,12 +196,12 @@ export function useContextMenu<
171196
return { x: clampedX, y: clampedY };
172197
};
173198

174-
// 'open' accepts an event for positioning, props required by the Component and opens the menu
199+
// 'open' accepts props, trigger props, and optional event for positioning, then opens the menu
175200
const open = useEvent(
176201
(
177-
event: NativeMouseEvent | NativePointerEvent | MouseEvent | PointerEvent,
178202
props?: P,
179203
triggerProps?: T,
204+
event?: NativeMouseEvent | NativePointerEvent | MouseEvent | PointerEvent,
180205
) => {
181206
setupCheck();
182207

@@ -194,6 +219,7 @@ export function useContextMenu<
194219

195220
// Prevent default context menu if it's a context menu event
196221
if (
222+
event &&
197223
'preventDefault' in event &&
198224
typeof event.preventDefault === 'function'
199225
) {
@@ -239,7 +265,7 @@ export function useContextMenu<
239265
const pos = calculatePosition(event);
240266
setAnchorPosition(pos);
241267
} else {
242-
open(event, defaultMenuProps);
268+
open(defaultMenuProps, undefined, event);
243269
}
244270
},
245271
);

0 commit comments

Comments
 (0)