Skip to content
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

Merged
merged 1 commit into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chatty-chairs-fail.md
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
6 changes: 3 additions & 3 deletions src/internal/primitives/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ export function Dialog({
<FocusTrap active={isOpen}>
<DismissableLayer onDismiss={onClose}>
<div
aria-modal={modal}
aria-describedby={ariaDescribedby}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
aria-modal={modal}
className="zoom-in-95 animate-in duration-200"
data-testid="ockDialog"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
Expand All @@ -64,7 +65,6 @@ export function Dialog({
}}
ref={dialogRef}
role="dialog"
className="zoom-in-95 animate-in duration-200"
>
{children}
</div>
Expand Down
17 changes: 17 additions & 0 deletions src/internal/primitives/DismissableLayer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,21 @@ describe('DismissableLayer', () => {
fireEvent.keyDown(document, { key: 'Escape' });
fireEvent.pointerDown(document.body);
});

it('does not call onDismiss when clicking the trigger button', () => {
render(
<>
<button type="button" aria-label="Toggle swap settings">
Trigger
</button>
<DismissableLayer onDismiss={onDismiss}>
<div>Test Content</div>
</DismissableLayer>
</>,
);

const triggerButton = screen.getByLabelText('Toggle swap settings');
fireEvent.pointerDown(triggerButton);
expect(onDismiss).not.toHaveBeenCalled();
});
});
44 changes: 20 additions & 24 deletions src/internal/primitives/DismissableLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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"]',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Meant to be hardcoded?

Copy link
Contributor Author

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2nd PR -> #1856

);
if (isTriggerClick) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i won't block on this, but I find the name trigger click confusing. what is a trigger?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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);
Expand All @@ -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>
);
Expand Down
224 changes: 224 additions & 0 deletions src/internal/primitives/Popover.test.tsx
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
Copy link
Contributor

Choose a reason for hiding this comment

The 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();
Copy link
Contributor

Choose a reason for hiding this comment

The 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();
Copy link
Contributor

Choose a reason for hiding this comment

The 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);
});
});
});
Loading
Loading