-
Notifications
You must be signed in to change notification settings - Fork 235
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Add Popover UI primitive #1849
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@coinbase/onchainkit": patch | ||
--- | ||
|
||
- **feat**: Add Popover UI Primitive. By @cpcramer #1849 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,8 +16,6 @@ export function DismissableLayer({ | |
onDismiss, | ||
}: DismissableLayerProps) { | ||
const layerRef = useRef<HTMLDivElement>(null); | ||
// Tracks whether the pointer event originated inside the React component tree | ||
const isPointerInsideReactTreeRef = useRef(false); | ||
|
||
useEffect(() => { | ||
if (disableOutsideClick && disableEscapeKey) { | ||
|
@@ -30,24 +28,30 @@ export function DismissableLayer({ | |
} | ||
}; | ||
|
||
const shouldDismiss = (target: Node) => { | ||
return layerRef.current && !layerRef.current.contains(target); | ||
}; | ||
|
||
// Handle clicks outside the layer | ||
const handlePointerDown = (event: PointerEvent) => { | ||
// Skip if outside clicks are disabled or if the click started inside the component | ||
if (disableOutsideClick || isPointerInsideReactTreeRef.current) { | ||
isPointerInsideReactTreeRef.current = false; | ||
if (disableOutsideClick) { | ||
return; | ||
} | ||
|
||
// Dismiss if click is outside the layer | ||
if (shouldDismiss(event.target as Node)) { | ||
onDismiss?.(); | ||
// If the click is inside the dismissable layer content, don't dismiss | ||
// This prevents the popover from closing when clicking inside it | ||
if (layerRef.current?.contains(event.target as Node)) { | ||
return; | ||
} | ||
// Reset the flag after handling the event | ||
isPointerInsideReactTreeRef.current = false; | ||
|
||
// Handling for the trigger button (e.g., settings toggle) | ||
// Without this, clicking the trigger would cause both: | ||
// 1. The button's onClick to fire (toggling isOpen) | ||
// 2. This dismissal logic to fire (forcing close) | ||
// This would create a race condition where the popover rapidly closes and reopens | ||
const isTriggerClick = (event.target as HTMLElement).closest( | ||
'[aria-label="Toggle swap settings"]', | ||
); | ||
if (isTriggerClick) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i won't block on this, but I find the name There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trigger is the button that opens the popup or dialog |
||
return; | ||
} | ||
|
||
onDismiss?.(); | ||
}; | ||
|
||
document.addEventListener('keydown', handleKeyDown); | ||
|
@@ -60,15 +64,7 @@ export function DismissableLayer({ | |
}, [disableOutsideClick, disableEscapeKey, onDismiss]); | ||
|
||
return ( | ||
<div | ||
data-testid="ockDismissableLayer" | ||
// Set flag when pointer event starts inside the component | ||
// This prevents dismissal when dragging from inside to outside | ||
onPointerDownCapture={() => { | ||
isPointerInsideReactTreeRef.current = true; | ||
}} | ||
ref={layerRef} | ||
> | ||
<div data-testid="ockDismissableLayer" ref={layerRef}> | ||
{children} | ||
</div> | ||
); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,224 @@ | ||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'; | ||
import userEvent from '@testing-library/user-event'; | ||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
import { Popover } from './Popover'; | ||
|
||
describe('Popover', () => { | ||
let anchorEl: HTMLElement; | ||
|
||
beforeEach(() => { | ||
Object.defineProperty(window, 'matchMedia', { | ||
writable: true, | ||
value: vi.fn().mockImplementation((query) => ({ | ||
matches: false, | ||
media: query, | ||
onchange: null, | ||
addListener: vi.fn(), | ||
removeListener: vi.fn(), | ||
addEventListener: vi.fn(), | ||
removeEventListener: vi.fn(), | ||
dispatchEvent: vi.fn(), | ||
})), | ||
}); | ||
|
||
anchorEl = document.createElement('button'); | ||
anchorEl.setAttribute('data-testid', 'anchor'); | ||
document.body.appendChild(anchorEl); | ||
}); | ||
|
||
afterEach(() => { | ||
cleanup(); | ||
document.body.innerHTML = ''; | ||
vi.clearAllMocks(); | ||
}); | ||
|
||
describe('rendering', () => { | ||
it('should not render when isOpen is false', () => { | ||
render( | ||
<Popover anchorEl={anchorEl} isOpen={false}> | ||
Content | ||
</Popover>, | ||
); | ||
|
||
expect(screen.queryByTestId('ockPopover')).not.toBeInTheDocument(); | ||
}); | ||
|
||
it('should render when isOpen is true', () => { | ||
render( | ||
<Popover anchorEl={anchorEl} isOpen={true}> | ||
Content | ||
</Popover>, | ||
); | ||
|
||
expect(screen.getByTestId('ockPopover')).toBeInTheDocument(); | ||
expect(screen.getByText('Content')).toBeInTheDocument(); | ||
}); | ||
|
||
it('should handle null anchorEl gracefully', () => { | ||
render( | ||
<Popover anchorEl={null} isOpen={true}> | ||
Content | ||
</Popover>, | ||
); | ||
|
||
expect(screen.getByTestId('ockPopover')).toBeInTheDocument(); | ||
}); | ||
}); | ||
|
||
describe('positioning', () => { | ||
const positions = ['top', 'right', 'bottom', 'left'] as const; | ||
const alignments = ['start', 'center', 'end'] as const; | ||
|
||
for (const position of positions) { | ||
for (const align of alignments) { | ||
it(`should position correctly with position=${position} and align=${align}`, () => { | ||
render( | ||
<Popover | ||
anchorEl={anchorEl} | ||
isOpen={true} | ||
position={position} | ||
align={align} | ||
offset={8} | ||
> | ||
Content | ||
</Popover>, | ||
); | ||
|
||
const popover = screen.getByTestId('ockPopover'); | ||
expect(popover).toBeInTheDocument(); | ||
|
||
expect(popover.style.top).toBeDefined(); | ||
expect(popover.style.left).toBeDefined(); | ||
Comment on lines
+90
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: seems like these should test to see if the correct values are passed in |
||
}); | ||
} | ||
} | ||
|
||
it('should update position on window resize', async () => { | ||
render( | ||
<Popover anchorEl={anchorEl} isOpen={true}> | ||
Content | ||
</Popover>, | ||
); | ||
|
||
fireEvent(window, new Event('resize')); | ||
|
||
expect(screen.getByTestId('ockPopover')).toBeInTheDocument(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmm, this doesn't seem like it's testing if position was updated |
||
}); | ||
|
||
it('should update position on scroll', async () => { | ||
render( | ||
<Popover anchorEl={anchorEl} isOpen={true}> | ||
Content | ||
</Popover>, | ||
); | ||
|
||
fireEvent.scroll(window); | ||
|
||
expect(screen.getByTestId('ockPopover')).toBeInTheDocument(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same comment here |
||
}); | ||
|
||
it('should handle missing getBoundingClientRect gracefully', () => { | ||
const originalGetBoundingClientRect = | ||
Element.prototype.getBoundingClientRect; | ||
Element.prototype.getBoundingClientRect = vi | ||
.fn() | ||
.mockReturnValue(undefined); | ||
|
||
render( | ||
<Popover anchorEl={anchorEl} isOpen={true}> | ||
Content | ||
</Popover>, | ||
); | ||
|
||
const popover = screen.getByTestId('ockPopover'); | ||
expect(popover).toBeInTheDocument(); | ||
|
||
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; | ||
}); | ||
}); | ||
|
||
describe('interactions', () => { | ||
it('should not call onClose when clicking inside', async () => { | ||
const onClose = vi.fn(); | ||
render( | ||
<Popover anchorEl={anchorEl} isOpen={true} onClose={onClose}> | ||
Content | ||
</Popover>, | ||
); | ||
|
||
fireEvent.mouseDown(screen.getByText('Content')); | ||
expect(onClose).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it('should call onClose when pressing Escape', async () => { | ||
const onClose = vi.fn(); | ||
render( | ||
<Popover anchorEl={anchorEl} isOpen={true} onClose={onClose}> | ||
Content | ||
</Popover>, | ||
); | ||
|
||
fireEvent.keyDown(document.body, { key: 'Escape' }); | ||
expect(onClose).toHaveBeenCalled(); | ||
}); | ||
}); | ||
|
||
describe('accessibility', () => { | ||
it('should have correct ARIA attributes', () => { | ||
render( | ||
<Popover | ||
anchorEl={anchorEl} | ||
isOpen={true} | ||
aria-label="Test Label" | ||
aria-labelledby="labelId" | ||
aria-describedby="describeId" | ||
> | ||
Content | ||
</Popover>, | ||
); | ||
|
||
const popover = screen.getByTestId('ockPopover'); | ||
expect(popover).toHaveAttribute('role', 'dialog'); | ||
expect(popover).toHaveAttribute('aria-label', 'Test Label'); | ||
expect(popover).toHaveAttribute('aria-labelledby', 'labelId'); | ||
expect(popover).toHaveAttribute('aria-describedby', 'describeId'); | ||
}); | ||
|
||
it('should trap focus when open', async () => { | ||
const user = userEvent.setup(); | ||
render( | ||
<Popover anchorEl={anchorEl} isOpen={true}> | ||
<button type="button">First</button> | ||
<button type="button">Second</button> | ||
</Popover>, | ||
); | ||
|
||
const firstButton = screen.getByText('First'); | ||
const secondButton = screen.getByText('Second'); | ||
|
||
firstButton.focus(); | ||
expect(document.activeElement).toBe(firstButton); | ||
|
||
await user.tab(); | ||
expect(document.activeElement).toBe(secondButton); | ||
|
||
await user.tab(); | ||
expect(document.activeElement).toBe(firstButton); | ||
}); | ||
}); | ||
|
||
describe('cleanup', () => { | ||
it('should remove event listeners on unmount', () => { | ||
const { unmount } = render( | ||
<Popover anchorEl={anchorEl} isOpen={true}> | ||
Content | ||
</Popover>, | ||
); | ||
|
||
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); | ||
unmount(); | ||
|
||
expect(removeEventListenerSpy).toHaveBeenCalledTimes(2); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Meant to be hardcoded?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, it will be passed as a param
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
2nd PR -> #1856