Skip to content

Commit

Permalink
feat: Add Popover UI primitive
Browse files Browse the repository at this point in the history
  • Loading branch information
cpcramer committed Jan 22, 2025
1 parent 567dd02 commit 2ed7e9d
Show file tree
Hide file tree
Showing 6 changed files with 594 additions and 64 deletions.
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
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"]',
);
if (isTriggerClick) {
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();
});
}
}

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();
});

it('should update position on scroll', async () => {
render(
<Popover anchorEl={anchorEl} isOpen={true}>
Content
</Popover>,
);

fireEvent.scroll(window);

expect(screen.getByTestId('ockPopover')).toBeInTheDocument();
});

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

0 comments on commit 2ed7e9d

Please sign in to comment.