Skip to content

Commit fa0cf6e

Browse files
committed
feat: Add Popover UI primitive
1 parent 567dd02 commit fa0cf6e

File tree

6 files changed

+448
-27
lines changed

6 files changed

+448
-27
lines changed

.changeset/chatty-chairs-fail.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@coinbase/onchainkit": patch
3+
---
4+
5+
- **feat**: Add Popover UI Primitive. By @cpcramer #1849

src/internal/primitives/Dialog.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,11 @@ export function Dialog({
5151
<FocusTrap active={isOpen}>
5252
<DismissableLayer onDismiss={onClose}>
5353
<div
54-
aria-modal={modal}
54+
aria-describedby={ariaDescribedby}
5555
aria-label={ariaLabel}
5656
aria-labelledby={ariaLabelledby}
57-
aria-describedby={ariaDescribedby}
57+
aria-modal={modal}
58+
className="zoom-in-95 animate-in duration-200"
5859
data-testid="ockDialog"
5960
onClick={(e) => e.stopPropagation()}
6061
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
@@ -64,7 +65,6 @@ export function Dialog({
6465
}}
6566
ref={dialogRef}
6667
role="dialog"
67-
className="zoom-in-95 animate-in duration-200"
6868
>
6969
{children}
7070
</div>

src/internal/primitives/DismissableLayer.test.tsx

+17
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,21 @@ describe('DismissableLayer', () => {
106106
fireEvent.keyDown(document, { key: 'Escape' });
107107
fireEvent.pointerDown(document.body);
108108
});
109+
110+
it('does not call onDismiss when clicking the trigger button', () => {
111+
render(
112+
<>
113+
<button type="button" aria-label="Toggle swap settings">
114+
Trigger
115+
</button>
116+
<DismissableLayer onDismiss={onDismiss}>
117+
<div>Test Content</div>
118+
</DismissableLayer>
119+
</>,
120+
);
121+
122+
const triggerButton = screen.getByLabelText('Toggle swap settings');
123+
fireEvent.pointerDown(triggerButton);
124+
expect(onDismiss).not.toHaveBeenCalled();
125+
});
109126
});

src/internal/primitives/DismissableLayer.tsx

+20-24
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ export function DismissableLayer({
1616
onDismiss,
1717
}: DismissableLayerProps) {
1818
const layerRef = useRef<HTMLDivElement>(null);
19-
// Tracks whether the pointer event originated inside the React component tree
20-
const isPointerInsideReactTreeRef = useRef(false);
2119

2220
useEffect(() => {
2321
if (disableOutsideClick && disableEscapeKey) {
@@ -30,24 +28,30 @@ export function DismissableLayer({
3028
}
3129
};
3230

33-
const shouldDismiss = (target: Node) => {
34-
return layerRef.current && !layerRef.current.contains(target);
35-
};
36-
37-
// Handle clicks outside the layer
3831
const handlePointerDown = (event: PointerEvent) => {
39-
// Skip if outside clicks are disabled or if the click started inside the component
40-
if (disableOutsideClick || isPointerInsideReactTreeRef.current) {
41-
isPointerInsideReactTreeRef.current = false;
32+
if (disableOutsideClick) {
4233
return;
4334
}
4435

45-
// Dismiss if click is outside the layer
46-
if (shouldDismiss(event.target as Node)) {
47-
onDismiss?.();
36+
// If the click is inside the dismissable layer content, don't dismiss
37+
// This prevents the popover from closing when clicking inside it
38+
if (layerRef.current?.contains(event.target as Node)) {
39+
return;
4840
}
49-
// Reset the flag after handling the event
50-
isPointerInsideReactTreeRef.current = false;
41+
42+
// Handling for the trigger button (e.g., settings toggle)
43+
// Without this, clicking the trigger would cause both:
44+
// 1. The button's onClick to fire (toggling isOpen)
45+
// 2. This dismissal logic to fire (forcing close)
46+
// This would create a race condition where the popover rapidly closes and reopens
47+
const isTriggerClick = (event.target as HTMLElement).closest(
48+
'[aria-label="Toggle swap settings"]',
49+
);
50+
if (isTriggerClick) {
51+
return;
52+
}
53+
54+
onDismiss?.();
5155
};
5256

5357
document.addEventListener('keydown', handleKeyDown);
@@ -60,15 +64,7 @@ export function DismissableLayer({
6064
}, [disableOutsideClick, disableEscapeKey, onDismiss]);
6165

6266
return (
63-
<div
64-
data-testid="ockDismissableLayer"
65-
// Set flag when pointer event starts inside the component
66-
// This prevents dismissal when dragging from inside to outside
67-
onPointerDownCapture={() => {
68-
isPointerInsideReactTreeRef.current = true;
69-
}}
70-
ref={layerRef}
71-
>
67+
<div data-testid="ockDismissableLayer" ref={layerRef}>
7268
{children}
7369
</div>
7470
);
+256
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { Popover } from './Popover';
4+
5+
vi.mock('react-dom', () => ({
6+
createPortal: (node: React.ReactNode) => node,
7+
}));
8+
9+
describe('Popover', () => {
10+
const onClose = vi.fn();
11+
let anchorEl: HTMLElement;
12+
13+
beforeEach(() => {
14+
vi.clearAllMocks();
15+
anchorEl = document.createElement('button');
16+
anchorEl.setAttribute('data-testid', 'anchor');
17+
document.body.appendChild(anchorEl);
18+
vi.spyOn(anchorEl, 'getBoundingClientRect').mockReturnValue({
19+
x: 100,
20+
y: 100,
21+
top: 100,
22+
right: 200,
23+
bottom: 200,
24+
left: 100,
25+
width: 100,
26+
height: 100,
27+
toJSON: () => {},
28+
});
29+
});
30+
31+
describe('rendering', () => {
32+
it('renders nothing when isOpen is false', () => {
33+
render(
34+
<Popover isOpen={false} anchorEl={anchorEl} onClose={onClose}>
35+
<div>Content</div>
36+
</Popover>,
37+
);
38+
expect(screen.queryByTestId('ockPopover')).not.toBeInTheDocument();
39+
});
40+
41+
it('renders content when isOpen is true', () => {
42+
render(
43+
<Popover isOpen={true} anchorEl={anchorEl} onClose={onClose}>
44+
<div data-testid="content">Content</div>
45+
</Popover>,
46+
);
47+
expect(screen.getByTestId('ockPopover')).toBeInTheDocument();
48+
expect(screen.getByTestId('content')).toBeInTheDocument();
49+
});
50+
});
51+
52+
describe('accessibility', () => {
53+
it('sets all ARIA attributes correctly', () => {
54+
render(
55+
<Popover
56+
isOpen={true}
57+
anchorEl={anchorEl}
58+
aria-label="Test Popover"
59+
aria-describedby="desc"
60+
aria-labelledby="title"
61+
onClose={onClose}
62+
>
63+
<div>Content</div>
64+
</Popover>,
65+
);
66+
67+
const popover = screen.getByTestId('ockPopover');
68+
expect(popover).toHaveAttribute('role', 'dialog');
69+
expect(popover).toHaveAttribute('aria-label', 'Test Popover');
70+
expect(popover).toHaveAttribute('aria-describedby', 'desc');
71+
expect(popover).toHaveAttribute('aria-labelledby', 'title');
72+
});
73+
});
74+
75+
describe('positioning', () => {
76+
const testCases = [
77+
{
78+
position: 'top',
79+
align: 'start',
80+
expectedTop: -8,
81+
expectedLeft: 100,
82+
},
83+
{
84+
position: 'bottom',
85+
align: 'center',
86+
expectedTop: 208,
87+
expectedLeft: 100,
88+
},
89+
{
90+
position: 'left',
91+
align: 'end',
92+
expectedTop: 200,
93+
expectedLeft: -8,
94+
},
95+
{
96+
position: 'right',
97+
align: 'center',
98+
expectedTop: 150,
99+
expectedLeft: 208,
100+
},
101+
] as const;
102+
103+
for (const { position, align, expectedTop, expectedLeft } of testCases) {
104+
it(`positions correctly with position=${position} and align=${align}`, () => {
105+
render(
106+
<Popover
107+
isOpen={true}
108+
anchorEl={anchorEl}
109+
position={position}
110+
align={align}
111+
offset={8}
112+
onClose={onClose}
113+
>
114+
<div style={{ width: '100px', height: '100px' }}>Content</div>
115+
</Popover>,
116+
);
117+
118+
const popover = screen.getByTestId('ockPopover');
119+
vi.spyOn(popover, 'getBoundingClientRect').mockReturnValue({
120+
width: 100,
121+
height: 100,
122+
} as DOMRect);
123+
124+
fireEvent(window, new Event('resize'));
125+
126+
expect(popover.style.top).toBe(`${expectedTop}px`);
127+
expect(popover.style.left).toBe(`${expectedLeft}px`);
128+
});
129+
}
130+
131+
it('updates position on scroll', () => {
132+
render(
133+
<Popover isOpen={true} anchorEl={anchorEl} onClose={onClose}>
134+
<div>Content</div>
135+
</Popover>,
136+
);
137+
138+
fireEvent.scroll(window);
139+
expect(anchorEl.getBoundingClientRect).toHaveBeenCalled();
140+
});
141+
});
142+
143+
describe('dismissal behavior', () => {
144+
it('calls onClose when clicking outside', () => {
145+
render(
146+
<Popover isOpen={true} anchorEl={anchorEl} onClose={onClose}>
147+
<div>Content</div>
148+
</Popover>,
149+
);
150+
151+
fireEvent.pointerDown(document.body);
152+
expect(onClose).toHaveBeenCalledTimes(1);
153+
});
154+
155+
it('calls onClose when pressing Escape', () => {
156+
render(
157+
<Popover isOpen={true} anchorEl={anchorEl} onClose={onClose}>
158+
<div>Content</div>
159+
</Popover>,
160+
);
161+
162+
fireEvent.keyDown(document, { key: 'Escape' });
163+
expect(onClose).toHaveBeenCalledTimes(1);
164+
});
165+
166+
it('handles undefined onClose prop gracefully', () => {
167+
render(
168+
<Popover isOpen={true} anchorEl={anchorEl}>
169+
<div>Content</div>
170+
</Popover>,
171+
);
172+
173+
fireEvent.pointerDown(document.body);
174+
fireEvent.keyDown(document, { key: 'Escape' });
175+
});
176+
});
177+
178+
describe('cleanup', () => {
179+
it('removes event listeners on unmount', () => {
180+
const { unmount } = render(
181+
<Popover isOpen={true} anchorEl={anchorEl} onClose={onClose}>
182+
<div>Content</div>
183+
</Popover>,
184+
);
185+
186+
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
187+
unmount();
188+
189+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
190+
'resize',
191+
expect.any(Function),
192+
);
193+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
194+
'scroll',
195+
expect.any(Function),
196+
);
197+
});
198+
});
199+
200+
describe('portal rendering', () => {
201+
it('renders in portal', () => {
202+
const { baseElement } = render(
203+
<Popover isOpen={true} anchorEl={anchorEl} onClose={onClose}>
204+
<div>Content</div>
205+
</Popover>,
206+
);
207+
208+
expect(baseElement.contains(screen.getByTestId('ockPopover'))).toBe(true);
209+
});
210+
});
211+
212+
describe('edge cases', () => {
213+
it('handles null anchorEl gracefully', () => {
214+
render(
215+
<Popover isOpen={true} anchorEl={null} onClose={onClose}>
216+
<div>Content</div>
217+
</Popover>,
218+
);
219+
expect(screen.getByTestId('ockPopover')).toBeInTheDocument();
220+
});
221+
222+
it('handles missing getBoundingClientRect gracefully', () => {
223+
const brokenAnchorEl = document.createElement('button');
224+
vi.spyOn(brokenAnchorEl, 'getBoundingClientRect').mockReturnValue(
225+
null as unknown as DOMRect,
226+
);
227+
228+
render(
229+
<Popover isOpen={true} anchorEl={brokenAnchorEl} onClose={onClose}>
230+
<div>Content</div>
231+
</Popover>,
232+
);
233+
expect(screen.getByTestId('ockPopover')).toBeInTheDocument();
234+
});
235+
});
236+
237+
describe('focus management', () => {
238+
it('traps focus when open', () => {
239+
render(
240+
<Popover isOpen={true} anchorEl={anchorEl} onClose={onClose}>
241+
<button type="button">First</button>
242+
<button type="button">Second</button>
243+
</Popover>,
244+
);
245+
246+
const firstButton = screen.getByText('First');
247+
const secondButton = screen.getByText('Second');
248+
249+
firstButton.focus();
250+
expect(document.activeElement).toBe(firstButton);
251+
252+
fireEvent.keyDown(firstButton, { key: 'Tab' });
253+
expect(document.activeElement).toBe(secondButton);
254+
});
255+
});
256+
});

0 commit comments

Comments
 (0)