feat: add Popover composite

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 13:59:38 +01:00
parent a4e32cc02f
commit 73bfab757f
3 changed files with 361 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
.wrapper {
position: relative;
display: inline-block;
}
.trigger {
display: inline-flex;
cursor: pointer;
}
/* Portal-rendered content — positioned via inline style */
.content {
position: absolute;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 12px;
z-index: 500;
animation: popoverIn 150ms ease-out;
min-width: 160px;
}
@keyframes popoverIn {
from {
opacity: 0;
transform: scale(0.97);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* ── Arrow ───────────────────────────────────────────────── */
.arrow {
position: absolute;
width: 0;
height: 0;
}
/* Arrow pointing UP (content is below trigger) */
.arrow-bottom {
top: -8px;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid var(--bg-surface);
}
/* Arrow pointing DOWN (content is above trigger) */
.arrow-top {
bottom: -8px;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid var(--bg-surface);
}
/* Arrow pointing RIGHT (content is to the left of trigger) */
.arrow-left {
right: -8px;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 8px solid var(--bg-surface);
}
/* Arrow pointing LEFT (content is to the right of trigger) */
.arrow-right {
left: -8px;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-right: 8px solid var(--bg-surface);
}
/* Arrow alignment for top/bottom positioned content */
.position-bottom.align-start .arrow-bottom,
.position-top.align-start .arrow-top {
left: 12px;
}
.position-bottom.align-center .arrow-bottom,
.position-top.align-center .arrow-top {
left: 50%;
transform: translateX(-50%);
}
.position-bottom.align-end .arrow-bottom,
.position-top.align-end .arrow-top {
right: 12px;
}
/* Arrow alignment for left/right positioned content */
.position-left.align-start .arrow-left,
.position-right.align-start .arrow-right {
top: 12px;
}
.position-left.align-center .arrow-left,
.position-right.align-center .arrow-right {
top: 50%;
transform: translateY(-50%);
}
.position-left.align-end .arrow-left,
.position-right.align-end .arrow-right {
bottom: 12px;
}

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Popover } from './Popover'
describe('Popover', () => {
it('does not show content initially', () => {
render(
<Popover trigger={<button>Open</button>} content={<p>Popover content</p>} />,
)
expect(screen.queryByText('Popover content')).not.toBeInTheDocument()
})
it('shows content on trigger click', async () => {
const user = userEvent.setup()
render(
<Popover trigger={<button>Open</button>} content={<p>Popover content</p>} />,
)
await user.click(screen.getByText('Open'))
expect(screen.getByText('Popover content')).toBeInTheDocument()
})
it('toggles closed on second trigger click', async () => {
const user = userEvent.setup()
render(
<Popover trigger={<button>Open</button>} content={<p>Popover content</p>} />,
)
await user.click(screen.getByText('Open'))
expect(screen.getByText('Popover content')).toBeInTheDocument()
await user.click(screen.getByText('Open'))
expect(screen.queryByText('Popover content')).not.toBeInTheDocument()
})
it('closes on Esc key', async () => {
const user = userEvent.setup()
render(
<Popover trigger={<button>Open</button>} content={<p>Popover content</p>} />,
)
await user.click(screen.getByText('Open'))
expect(screen.getByText('Popover content')).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(screen.queryByText('Popover content')).not.toBeInTheDocument()
})
it('closes on outside click', async () => {
const user = userEvent.setup()
render(
<div>
<Popover trigger={<button>Open</button>} content={<p>Popover content</p>} />
<button>Outside</button>
</div>,
)
await user.click(screen.getByText('Open'))
expect(screen.getByText('Popover content')).toBeInTheDocument()
await user.click(screen.getByText('Outside'))
expect(screen.queryByText('Popover content')).not.toBeInTheDocument()
})
it('renders content via portal into document.body', async () => {
const user = userEvent.setup()
render(
<Popover trigger={<button>Open</button>} content={<p>Popover content</p>} />,
)
await user.click(screen.getByText('Open'))
const contentEl = screen.getByTestId('popover-content')
expect(document.body.contains(contentEl)).toBe(true)
})
it('accepts position prop without error', async () => {
const user = userEvent.setup()
render(
<Popover
trigger={<button>Open</button>}
content={<p>Top content</p>}
position="top"
/>,
)
await user.click(screen.getByText('Open'))
expect(screen.getByText('Top content')).toBeInTheDocument()
})
it('accepts align prop without error', async () => {
const user = userEvent.setup()
render(
<Popover
trigger={<button>Open</button>}
content={<p>Start aligned</p>}
position="bottom"
align="start"
/>,
)
await user.click(screen.getByText('Open'))
expect(screen.getByText('Start aligned')).toBeInTheDocument()
})
it('does not close when clicking inside the content panel', async () => {
const user = userEvent.setup()
render(
<Popover
trigger={<button>Open</button>}
content={<button>Inner button</button>}
/>,
)
await user.click(screen.getByText('Open'))
await user.click(screen.getByText('Inner button'))
expect(screen.getByText('Inner button')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,146 @@
import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import styles from './Popover.module.css'
export interface PopoverProps {
trigger: ReactNode
content: ReactNode
position?: 'top' | 'bottom' | 'left' | 'right'
align?: 'start' | 'center' | 'end'
className?: string
}
interface ContentStyle {
top: number
left: number
}
function getContentStyle(
triggerRect: DOMRect,
contentEl: HTMLDivElement | null,
position: 'top' | 'bottom' | 'left' | 'right',
align: 'start' | 'center' | 'end',
): ContentStyle {
const ARROW_SIZE = 8
const GAP = ARROW_SIZE + 4
const contentWidth = contentEl?.offsetWidth ?? 200
const contentHeight = contentEl?.offsetHeight ?? 100
let top = 0
let left = 0
// Main axis
if (position === 'bottom') {
top = triggerRect.bottom + window.scrollY + GAP
} else if (position === 'top') {
top = triggerRect.top + window.scrollY - contentHeight - GAP
} else if (position === 'left') {
left = triggerRect.left + window.scrollX - contentWidth - GAP
} else if (position === 'right') {
left = triggerRect.right + window.scrollX + GAP
}
// Cross axis alignment
if (position === 'top' || position === 'bottom') {
if (align === 'start') {
left = triggerRect.left + window.scrollX
} else if (align === 'center') {
left = triggerRect.left + window.scrollX + triggerRect.width / 2 - contentWidth / 2
} else {
left = triggerRect.right + window.scrollX - contentWidth
}
} else {
if (align === 'start') {
top = triggerRect.top + window.scrollY
} else if (align === 'center') {
top = triggerRect.top + window.scrollY + triggerRect.height / 2 - contentHeight / 2
} else {
top = triggerRect.bottom + window.scrollY - contentHeight
}
}
return { top, left }
}
export function Popover({
trigger,
content,
position = 'bottom',
align = 'center',
className,
}: PopoverProps) {
const [open, setOpen] = useState(false)
const [style, setStyle] = useState<ContentStyle>({ top: 0, left: 0 })
const triggerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const recalculate = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
setStyle(getContentStyle(rect, contentRef.current, position, align))
}, [position, align])
// Recalculate after content renders
useEffect(() => {
if (open) {
// Allow the DOM to paint once, then measure
const id = requestAnimationFrame(() => {
recalculate()
})
return () => cancelAnimationFrame(id)
}
}, [open, recalculate])
// Close on outside click
useEffect(() => {
if (!open) return
function handleMouseDown(e: MouseEvent) {
if (
triggerRef.current &&
!triggerRef.current.contains(e.target as Node) &&
contentRef.current &&
!contentRef.current.contains(e.target as Node)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleMouseDown)
return () => document.removeEventListener('mousedown', handleMouseDown)
}, [open])
// Close on Esc
useEffect(() => {
if (!open) return
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [open])
return (
<div ref={triggerRef} className={`${styles.wrapper} ${className ?? ''}`}>
<div
className={styles.trigger}
onClick={() => setOpen((prev) => !prev)}
>
{trigger}
</div>
{open &&
createPortal(
<div
ref={contentRef}
className={`${styles.content} ${styles[`position-${position}`]} ${styles[`align-${align}`]}`}
style={{ top: style.top, left: style.left }}
role="dialog"
data-testid="popover-content"
>
<div className={`${styles.arrow} ${styles[`arrow-${position}`]}`} aria-hidden="true" />
{content}
</div>,
document.body,
)}
</div>
)
}