From 31f1cc20405d241c46ec0da7a466a922712935be Mon Sep 17 00:00:00 2001 From: Paul Cramer Date: Thu, 23 Jan 2025 09:31:30 -0800 Subject: [PATCH] feat: Add Popover UI primitive (#1849) --- .changeset/chatty-chairs-fail.md | 5 + src/internal/primitives/Dialog.tsx | 6 +- .../primitives/DismissableLayer.test.tsx | 17 ++ src/internal/primitives/DismissableLayer.tsx | 44 ++-- src/internal/primitives/Popover.test.tsx | 224 ++++++++++++++++++ src/internal/primitives/Popover.tsx | 191 +++++++++++++++ 6 files changed, 460 insertions(+), 27 deletions(-) create mode 100644 .changeset/chatty-chairs-fail.md create mode 100644 src/internal/primitives/Popover.test.tsx create mode 100644 src/internal/primitives/Popover.tsx diff --git a/.changeset/chatty-chairs-fail.md b/.changeset/chatty-chairs-fail.md new file mode 100644 index 0000000000..4e1e20edb7 --- /dev/null +++ b/.changeset/chatty-chairs-fail.md @@ -0,0 +1,5 @@ +--- +"@coinbase/onchainkit": patch +--- + +- **feat**: Add Popover UI Primitive. By @cpcramer #1849 diff --git a/src/internal/primitives/Dialog.tsx b/src/internal/primitives/Dialog.tsx index e60d9a65d4..bfda47ad41 100644 --- a/src/internal/primitives/Dialog.tsx +++ b/src/internal/primitives/Dialog.tsx @@ -51,10 +51,11 @@ export function Dialog({
e.stopPropagation()} onKeyDown={(e: React.KeyboardEvent) => { @@ -64,7 +65,6 @@ export function Dialog({ }} ref={dialogRef} role="dialog" - className="zoom-in-95 animate-in duration-200" > {children}
diff --git a/src/internal/primitives/DismissableLayer.test.tsx b/src/internal/primitives/DismissableLayer.test.tsx index abf55879c5..4fd103ac8f 100644 --- a/src/internal/primitives/DismissableLayer.test.tsx +++ b/src/internal/primitives/DismissableLayer.test.tsx @@ -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( + <> + + +
Test Content
+
+ , + ); + + const triggerButton = screen.getByLabelText('Toggle swap settings'); + fireEvent.pointerDown(triggerButton); + expect(onDismiss).not.toHaveBeenCalled(); + }); }); diff --git a/src/internal/primitives/DismissableLayer.tsx b/src/internal/primitives/DismissableLayer.tsx index c6a1aadf52..62c35f3513 100644 --- a/src/internal/primitives/DismissableLayer.tsx +++ b/src/internal/primitives/DismissableLayer.tsx @@ -16,8 +16,6 @@ export function DismissableLayer({ onDismiss, }: DismissableLayerProps) { const layerRef = useRef(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) { + return; + } + + onDismiss?.(); }; document.addEventListener('keydown', handleKeyDown); @@ -60,15 +64,7 @@ export function DismissableLayer({ }, [disableOutsideClick, disableEscapeKey, onDismiss]); return ( -
{ - isPointerInsideReactTreeRef.current = true; - }} - ref={layerRef} - > +
{children}
); diff --git a/src/internal/primitives/Popover.test.tsx b/src/internal/primitives/Popover.test.tsx new file mode 100644 index 0000000000..0c847bfb83 --- /dev/null +++ b/src/internal/primitives/Popover.test.tsx @@ -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( + + Content + , + ); + + expect(screen.queryByTestId('ockPopover')).not.toBeInTheDocument(); + }); + + it('should render when isOpen is true', () => { + render( + + Content + , + ); + + expect(screen.getByTestId('ockPopover')).toBeInTheDocument(); + expect(screen.getByText('Content')).toBeInTheDocument(); + }); + + it('should handle null anchorEl gracefully', () => { + render( + + Content + , + ); + + 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( + + Content + , + ); + + 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( + + Content + , + ); + + fireEvent(window, new Event('resize')); + + expect(screen.getByTestId('ockPopover')).toBeInTheDocument(); + }); + + it('should update position on scroll', async () => { + render( + + Content + , + ); + + 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( + + Content + , + ); + + 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( + + Content + , + ); + + fireEvent.mouseDown(screen.getByText('Content')); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('should call onClose when pressing Escape', async () => { + const onClose = vi.fn(); + render( + + Content + , + ); + + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); + }); + + describe('accessibility', () => { + it('should have correct ARIA attributes', () => { + render( + + Content + , + ); + + 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( + + + + , + ); + + 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( + + Content + , + ); + + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/internal/primitives/Popover.tsx b/src/internal/primitives/Popover.tsx new file mode 100644 index 0000000000..a1b82fc60c --- /dev/null +++ b/src/internal/primitives/Popover.tsx @@ -0,0 +1,191 @@ +import type React from 'react'; +import { useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useTheme } from '../../core-react/internal/hooks/useTheme'; +import { cn } from '../../styles/theme'; +import { DismissableLayer } from './DismissableLayer'; +import { FocusTrap } from './FocusTrap'; + +type Position = 'top' | 'right' | 'bottom' | 'left'; +type Alignment = 'start' | 'center' | 'end'; + +type PopoverProps = { + align?: Alignment; // Determines how the popover aligns with the anchor + anchorEl: HTMLElement | null; + children?: React.ReactNode; + onClose?: () => void; + offset?: number; // Spacing (in pixels) between the anchor element and the popover content. + position?: Position; // Determines which side of the anchor element the popover will appear. + isOpen?: boolean; + 'aria-label'?: string; + 'aria-labelledby'?: string; + 'aria-describedby'?: string; +}; + +/** + * Calculates the initial position of the popover based on the position prop. + */ +function getInitialPosition( + triggerRect: DOMRect, + contentRect: DOMRect, + position: Position, + offset: number, +): { top: number; left: number } { + let top = 0; + let left = 0; + + switch (position) { + case 'top': + top = triggerRect.top - contentRect.height - offset; + break; + case 'bottom': + top = triggerRect.bottom + offset; + break; + case 'left': + left = triggerRect.left - contentRect.width - offset; + break; + case 'right': + left = triggerRect.right + offset; + break; + } + + return { top, left }; +} + +/** + * Adjusts the initial position based on the alignment prop. + */ +function adjustAlignment( + triggerRect: DOMRect, + contentRect: DOMRect, + initialPosition: { top: number; left: number }, + align: Alignment, + position: Position, +): { top: number; left: number } { + const { top: initialTop, left: initialLeft } = initialPosition; + let top = initialTop; + let left = initialLeft; + + const isVerticalPosition = position === 'top' || position === 'bottom'; + + switch (align) { + case 'start': + if (isVerticalPosition) { + left = triggerRect.left; + } else { + top = triggerRect.top; + } + break; + case 'center': + if (isVerticalPosition) { + left = triggerRect.left + (triggerRect.width - contentRect.width) / 2; + } else { + top = triggerRect.top + (triggerRect.height - contentRect.height) / 2; + } + break; + case 'end': + if (isVerticalPosition) { + left = triggerRect.right - contentRect.width; + } else { + top = triggerRect.bottom - contentRect.height; + } + break; + } + + return { top, left }; +} + +/** + * Popover primitive that handles: + * - Positioning relative to anchor element + * - Focus management + * - Click outside and escape key dismissal + * - Portal rendering + * - Proper ARIA attributes + */ +export function Popover({ + children, + anchorEl, + isOpen, + onClose, + position = 'bottom', + align = 'center', + offset = 8, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, +}: PopoverProps) { + const contentRef = useRef(null); + const componentTheme = useTheme(); + + const updatePosition = useCallback(() => { + if (!anchorEl || !contentRef.current) { + return; + } + + const triggerRect = anchorEl.getBoundingClientRect(); + const contentRect = contentRef.current?.getBoundingClientRect(); + + if (!triggerRect || !contentRect) { + return; + } + + const initialPosition = getInitialPosition( + triggerRect, + contentRect, + position, + offset, + ); + const finalPosition = adjustAlignment( + triggerRect, + contentRect, + initialPosition, + align, + position, + ); + + contentRef.current.style.top = `${finalPosition.top}px`; + contentRef.current.style.left = `${finalPosition.left}px`; + }, [anchorEl, position, offset, align]); + + useEffect(() => { + if (!isOpen) { + return; + } + + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition); + + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition); + }; + }, [isOpen, updatePosition]); + + if (!isOpen) { + return null; + } + + const popover = ( +
+ + +
+ {children} +
+
+
+
+ ); + + return createPortal(popover, document.body); +}