feat: add Popover composite
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
107
src/design-system/composites/Popover/Popover.module.css
Normal file
107
src/design-system/composites/Popover/Popover.module.css
Normal 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;
|
||||
}
|
||||
108
src/design-system/composites/Popover/Popover.test.tsx
Normal file
108
src/design-system/composites/Popover/Popover.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
146
src/design-system/composites/Popover/Popover.tsx
Normal file
146
src/design-system/composites/Popover/Popover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user