feat: Collapsible and Tooltip primitives

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 09:33:43 +01:00
parent ebf5fc6cb7
commit 52cf5129f7
5 changed files with 225 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
.root {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 14px;
background: var(--bg-raised);
border: none;
cursor: pointer;
text-align: left;
font-family: var(--font-body);
font-size: 13px;
color: var(--text-primary);
transition: background 0.15s;
}
.trigger:hover {
background: var(--bg-hover);
}
.title {
font-weight: 500;
}
.chevron {
color: var(--text-muted);
font-size: 14px;
transition: transform 0.2s;
transform: rotate(-90deg);
}
.chevronOpen {
transform: rotate(0deg);
}
.content {
max-height: 0;
overflow: hidden;
transition: max-height 0.25s ease;
}
.content[hidden] {
display: block;
max-height: 0;
}
.contentOpen {
max-height: 2000px;
}
.inner {
padding: 12px 14px;
}

View File

@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Collapsible } from './Collapsible'
describe('Collapsible', () => {
it('renders title', () => {
render(<Collapsible title="Details">Content</Collapsible>)
expect(screen.getByText('Details')).toBeInTheDocument()
})
it('hides content by default', () => {
render(<Collapsible title="Details">Hidden content</Collapsible>)
expect(screen.queryByText('Hidden content')).not.toBeVisible()
})
it('shows content when defaultOpen', () => {
render(<Collapsible title="Details" defaultOpen>Visible content</Collapsible>)
expect(screen.getByText('Visible content')).toBeVisible()
})
it('toggles content on click', async () => {
const user = userEvent.setup()
render(<Collapsible title="Details">Content</Collapsible>)
await user.click(screen.getByText('Details'))
expect(screen.getByText('Content')).toBeVisible()
})
it('calls onToggle when toggled', async () => {
const onToggle = vi.fn()
const user = userEvent.setup()
render(<Collapsible title="Details" onToggle={onToggle}>Content</Collapsible>)
await user.click(screen.getByText('Details'))
expect(onToggle).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,53 @@
import { useState, useRef, useEffect } from 'react'
import styles from './Collapsible.module.css'
import type { ReactNode } from 'react'
interface CollapsibleProps {
title: ReactNode
children: ReactNode
defaultOpen?: boolean
open?: boolean
onToggle?: (open: boolean) => void
className?: string
}
export function Collapsible({
title,
children,
defaultOpen = false,
open: controlledOpen,
onToggle,
className,
}: CollapsibleProps) {
const isControlled = controlledOpen !== undefined
const [internalOpen, setInternalOpen] = useState(defaultOpen)
const open = isControlled ? controlledOpen : internalOpen
const contentRef = useRef<HTMLDivElement>(null)
function handleToggle() {
const next = !open
if (!isControlled) setInternalOpen(next)
onToggle?.(next)
}
return (
<div className={`${styles.root} ${className ?? ''}`}>
<button
type="button"
className={styles.trigger}
onClick={handleToggle}
aria-expanded={open}
>
<span className={styles.title}>{title}</span>
<span className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}></span>
</button>
<div
ref={contentRef}
className={`${styles.content} ${open ? styles.contentOpen : ''}`}
hidden={!open}
>
<div className={styles.inner}>{children}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
.wrapper {
position: relative;
display: inline-flex;
align-items: center;
}
.tip {
position: absolute;
z-index: 100;
padding: 5px 9px;
background: var(--text-primary);
color: var(--bg-surface);
font-size: 11px;
font-family: var(--font-body);
border-radius: var(--radius-sm);
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
box-shadow: var(--shadow-md);
}
.wrapper:hover .tip {
opacity: 1;
}
/* Positions */
.top {
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
}
.bottom {
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
}
.left {
right: calc(100% + 6px);
top: 50%;
transform: translateY(-50%);
}
.right {
left: calc(100% + 6px);
top: 50%;
transform: translateY(-50%);
}

View File

@@ -0,0 +1,27 @@
import styles from './Tooltip.module.css'
import type { ReactNode } from 'react'
type TooltipPosition = 'top' | 'bottom' | 'left' | 'right'
interface TooltipProps {
content: ReactNode
position?: TooltipPosition
children: ReactNode
className?: string
}
export function Tooltip({
content,
position = 'top',
children,
className,
}: TooltipProps) {
return (
<span className={`${styles.wrapper} ${className ?? ''}`}>
{children}
<span className={`${styles.tip} ${styles[position]}`} role="tooltip">
{content}
</span>
</span>
)
}