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