diff --git a/src/design-system/composites/Popover/Popover.module.css b/src/design-system/composites/Popover/Popover.module.css new file mode 100644 index 0000000..05e192a --- /dev/null +++ b/src/design-system/composites/Popover/Popover.module.css @@ -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; +} diff --git a/src/design-system/composites/Popover/Popover.test.tsx b/src/design-system/composites/Popover/Popover.test.tsx new file mode 100644 index 0000000..ec5b4cf --- /dev/null +++ b/src/design-system/composites/Popover/Popover.test.tsx @@ -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( + Open} content={

Popover content

} />, + ) + expect(screen.queryByText('Popover content')).not.toBeInTheDocument() + }) + + it('shows content on trigger click', async () => { + const user = userEvent.setup() + render( + Open} content={

Popover content

} />, + ) + 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( + Open} content={

Popover content

} />, + ) + 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( + Open} content={

Popover content

} />, + ) + 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( +
+ Open} content={

Popover content

} /> + +
, + ) + 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( + Open} content={

Popover content

} />, + ) + 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( + Open} + content={

Top content

} + 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( + Open} + content={

Start aligned

} + 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( + Open} + content={} + />, + ) + await user.click(screen.getByText('Open')) + await user.click(screen.getByText('Inner button')) + expect(screen.getByText('Inner button')).toBeInTheDocument() + }) +}) diff --git a/src/design-system/composites/Popover/Popover.tsx b/src/design-system/composites/Popover/Popover.tsx new file mode 100644 index 0000000..c534861 --- /dev/null +++ b/src/design-system/composites/Popover/Popover.tsx @@ -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({ top: 0, left: 0 }) + const triggerRef = useRef(null) + const contentRef = useRef(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 ( +
+
setOpen((prev) => !prev)} + > + {trigger} +
+ {open && + createPortal( +
+ , + document.body, + )} +
+ ) +}