diff --git a/src/design-system/primitives/Collapsible/Collapsible.module.css b/src/design-system/primitives/Collapsible/Collapsible.module.css new file mode 100644 index 0000000..35fa720 --- /dev/null +++ b/src/design-system/primitives/Collapsible/Collapsible.module.css @@ -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; +} diff --git a/src/design-system/primitives/Collapsible/Collapsible.test.tsx b/src/design-system/primitives/Collapsible/Collapsible.test.tsx new file mode 100644 index 0000000..75a6e56 --- /dev/null +++ b/src/design-system/primitives/Collapsible/Collapsible.test.tsx @@ -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(Content) + expect(screen.getByText('Details')).toBeInTheDocument() + }) + + it('hides content by default', () => { + render(Hidden content) + expect(screen.queryByText('Hidden content')).not.toBeVisible() + }) + + it('shows content when defaultOpen', () => { + render(Visible content) + expect(screen.getByText('Visible content')).toBeVisible() + }) + + it('toggles content on click', async () => { + const user = userEvent.setup() + render(Content) + 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(Content) + await user.click(screen.getByText('Details')) + expect(onToggle).toHaveBeenCalled() + }) +}) diff --git a/src/design-system/primitives/Collapsible/Collapsible.tsx b/src/design-system/primitives/Collapsible/Collapsible.tsx new file mode 100644 index 0000000..52b2881 --- /dev/null +++ b/src/design-system/primitives/Collapsible/Collapsible.tsx @@ -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(null) + + function handleToggle() { + const next = !open + if (!isControlled) setInternalOpen(next) + onToggle?.(next) + } + + return ( +
+ + +
+ ) +} diff --git a/src/design-system/primitives/Tooltip/Tooltip.module.css b/src/design-system/primitives/Tooltip/Tooltip.module.css new file mode 100644 index 0000000..ed4f4fd --- /dev/null +++ b/src/design-system/primitives/Tooltip/Tooltip.module.css @@ -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%); +} diff --git a/src/design-system/primitives/Tooltip/Tooltip.tsx b/src/design-system/primitives/Tooltip/Tooltip.tsx new file mode 100644 index 0000000..4c7ba90 --- /dev/null +++ b/src/design-system/primitives/Tooltip/Tooltip.tsx @@ -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 ( + + {children} + + {content} + + + ) +}