initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
@@ -0,0 +1,264 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Database,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
Menu,
|
||||
Network,
|
||||
PanelLeftClose,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Users,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { logout } from '../../lib/api.ts'
|
||||
import type { Health } from '../../lib/types.ts'
|
||||
import { HealthPill } from './HealthPill.tsx'
|
||||
import { ThemeToggle } from './ThemeToggle.tsx'
|
||||
import { Tooltip } from '../ui/Tooltip.tsx'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
end?: boolean
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
||||
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
|
||||
{ to: '/domains', label: 'Domains', icon: Globe },
|
||||
{ to: '/clients', label: 'Clients', icon: Users },
|
||||
{ to: '/upstreams', label: 'Upstreams', icon: Network },
|
||||
{ to: '/logs', label: 'Logs', icon: ScrollText },
|
||||
{ to: '/cache', label: 'Cache', icon: Database },
|
||||
{ to: '/settings', label: 'Settings', icon: Settings },
|
||||
]
|
||||
|
||||
const MOBILE_QUERY = '(max-width: 1023px)'
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
health,
|
||||
nodeId,
|
||||
}: {
|
||||
children: ReactNode
|
||||
health?: Health
|
||||
nodeId?: string
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [signingOut, setSigningOut] = useState(false)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia(MOBILE_QUERY)
|
||||
const sync = (): void => {
|
||||
setIsMobile(query.matches)
|
||||
if (!query.matches) setSheetOpen(false)
|
||||
}
|
||||
sync()
|
||||
query.addEventListener('change', sync)
|
||||
return () => query.removeEventListener('change', sync)
|
||||
}, [])
|
||||
|
||||
// Any navigation closes the mobile sheet.
|
||||
useEffect(() => {
|
||||
setSheetOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sheetOpen) return
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') setSheetOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
const previous = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.body.style.overflow = previous
|
||||
}
|
||||
}, [sheetOpen])
|
||||
|
||||
const onSignOut = useCallback(() => {
|
||||
setSigningOut(true)
|
||||
logout()
|
||||
.catch(() => {
|
||||
/* the token is cleared either way */
|
||||
})
|
||||
.finally(() => {
|
||||
setSigningOut(false)
|
||||
navigate('/auth/login', { replace: true })
|
||||
})
|
||||
}, [navigate])
|
||||
|
||||
const rail = !isMobile && collapsed
|
||||
|
||||
return (
|
||||
<div className="min-h-svh bg-canvas">
|
||||
{/* Desktop rail toggle lives in the top bar; on mobile the bar carries
|
||||
the hamburger that opens the sheet instead. */}
|
||||
<header className="fixed inset-x-0 top-0 z-40 flex h-14 items-center gap-2 border-b border-line bg-surface-2 px-3">
|
||||
{isMobile ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open navigation"
|
||||
onClick={() => setSheetOpen(true)}
|
||||
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<Menu size={18} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<PanelLeftClose
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
className={cn('transition-transform', collapsed && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-accent-soft text-accent">
|
||||
<Globe size={15} strokeWidth={2.25} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="truncate text-sm font-semibold tracking-tight text-fg">fancy-dns</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{nodeId !== undefined && nodeId !== '' && (
|
||||
<span
|
||||
title={`Node ${nodeId}`}
|
||||
className="tabular hidden max-w-40 truncate rounded-md border border-line bg-surface px-2 py-1 font-mono text-xs text-fg-muted sm:inline-block"
|
||||
>
|
||||
{nodeId}
|
||||
</span>
|
||||
)}
|
||||
{health !== undefined && <HealthPill health={health} />}
|
||||
<ThemeToggle />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Sign out"
|
||||
title="Sign out"
|
||||
disabled={signingOut}
|
||||
onClick={onSignOut}
|
||||
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg disabled:opacity-50"
|
||||
>
|
||||
<LogOut size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Desktop sidebar. */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed top-14 bottom-0 left-0 z-30 hidden shrink-0 border-r border-line bg-surface transition-[width] duration-200 lg:flex lg:flex-col',
|
||||
rail ? 'w-16' : 'w-56',
|
||||
)}
|
||||
>
|
||||
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
{NAV.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-soft text-accent'
|
||||
: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
|
||||
rail && 'justify-center px-0',
|
||||
)
|
||||
}
|
||||
title={rail ? item.label : undefined}
|
||||
>
|
||||
<item.icon size={16} strokeWidth={2} aria-hidden="true" />
|
||||
{!rail && <span className="truncate">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Mobile sheet. */}
|
||||
{sheetOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close navigation"
|
||||
onClick={() => setSheetOpen(false)}
|
||||
className="absolute inset-0 bg-black/60"
|
||||
/>
|
||||
<aside
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Navigation"
|
||||
className="absolute inset-y-0 left-0 flex w-64 flex-col border-r border-line bg-surface shadow-panel"
|
||||
>
|
||||
<div className="flex h-14 items-center justify-between border-b border-line px-3">
|
||||
<span className="text-sm font-semibold text-fg">fancy-dns</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close navigation"
|
||||
onClick={() => setSheetOpen(false)}
|
||||
className="inline-flex size-8 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<X size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
{NAV.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={() => setSheetOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-soft text-accent'
|
||||
: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon size={16} strokeWidth={2} aria-hidden="true" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
'pt-14 transition-[padding] duration-200',
|
||||
isMobile ? 'px-3 py-4' : rail ? 'pl-16' : 'pl-56',
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[1600px] flex-col gap-4 p-3 sm:p-4 lg:p-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-exported so pages can label collapsed rail items without importing ui. */
|
||||
export { Tooltip }
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Health } from '../../lib/types.ts'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
const STYLES: Record<Health, { dot: string; text: string; ring: string }> = {
|
||||
ok: { dot: 'bg-ok', text: 'text-ok', ring: 'border-ok/30 bg-ok-soft' },
|
||||
degraded: { dot: 'bg-warn', text: 'text-warn', ring: 'border-warn/30 bg-warn-soft' },
|
||||
down: { dot: 'bg-danger', text: 'text-danger', ring: 'border-danger/30 bg-danger-soft' },
|
||||
}
|
||||
|
||||
const DEFAULT_LABEL: Record<Health, string> = {
|
||||
ok: 'Healthy',
|
||||
degraded: 'Degraded',
|
||||
down: 'Down',
|
||||
}
|
||||
|
||||
export function HealthPill({ health, label, className }: { health: Health; label?: string; className?: string }) {
|
||||
const style = STYLES[health] ?? STYLES.degraded
|
||||
return (
|
||||
<span
|
||||
title={`Node health: ${health}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-xs font-medium whitespace-nowrap',
|
||||
style.ring,
|
||||
style.text,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full', style.dot, health === 'down' && 'animate-pulse-soft')} />
|
||||
{label ?? DEFAULT_LABEL[health] ?? health}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function LoadingBlock({ rows = 5, className }: { rows?: number; className?: string }) {
|
||||
const count = Math.max(1, Math.min(rows, 50))
|
||||
return (
|
||||
<div className={cn('flex w-full flex-col gap-2', className)} aria-busy="true" aria-live="polite">
|
||||
<span className="sr-only">Loading…</span>
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function PageError({
|
||||
error,
|
||||
onRetry,
|
||||
className,
|
||||
}: {
|
||||
error: string
|
||||
onRetry?: () => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-3 rounded-xl border border-danger/30 bg-danger-soft p-4 sm:flex-row sm:items-center sm:justify-between',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<AlertTriangle size={18} strokeWidth={2} className="mt-0.5 shrink-0 text-danger" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-fg">Could not load this view</p>
|
||||
<p className="mt-0.5 text-sm break-words text-fg-muted">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
{onRetry && (
|
||||
<Button variant="outline" icon={RefreshCw} onClick={onRetry} className="shrink-0">
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
className,
|
||||
}: {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<header className={cn('flex flex-wrap items-start justify-between gap-3', className)}>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold tracking-tight text-fg">{title}</h1>
|
||||
{description !== undefined && (
|
||||
<p className="mt-0.5 text-sm text-fg-muted">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions !== undefined && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export type Theme = 'dark' | 'light'
|
||||
|
||||
const STORAGE_KEY = 'fancydns.theme'
|
||||
|
||||
function readStoredTheme(): Theme {
|
||||
try {
|
||||
const value = localStorage.getItem(STORAGE_KEY)
|
||||
if (value === 'light' || value === 'dark') return value
|
||||
} catch {
|
||||
/* storage disabled: fall through to the default */
|
||||
}
|
||||
return 'dark'
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs at module evaluation, i.e. while the bundle is still being parsed and
|
||||
* before the first paint, so the console never flashes the wrong theme.
|
||||
*/
|
||||
const INITIAL_THEME: Theme = (() => {
|
||||
if (typeof document === 'undefined') return 'dark'
|
||||
const theme = readStoredTheme()
|
||||
document.documentElement.dataset.theme = theme
|
||||
return theme
|
||||
})()
|
||||
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
const [theme, setTheme] = useState<Theme>(INITIAL_THEME)
|
||||
|
||||
// A second tab changing the theme should not desync this one.
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent): void => {
|
||||
if (event.key !== STORAGE_KEY) return
|
||||
const next = readStoredTheme()
|
||||
document.documentElement.dataset.theme = next
|
||||
setTheme(next)
|
||||
}
|
||||
window.addEventListener('storage', onStorage)
|
||||
return () => window.removeEventListener('storage', onStorage)
|
||||
}, [])
|
||||
|
||||
const apply = (next: Theme): void => {
|
||||
document.documentElement.dataset.theme = next
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
} catch {
|
||||
/* not fatal: the choice just does not survive a reload */
|
||||
}
|
||||
setTheme(next)
|
||||
}
|
||||
|
||||
const next: Theme = theme === 'dark' ? 'light' : 'dark'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => apply(next)}
|
||||
title={`Switch to ${next} theme`}
|
||||
aria-label={`Switch to ${next} theme`}
|
||||
className={cn(
|
||||
'inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Moon size={16} strokeWidth={2} aria-hidden="true" />
|
||||
) : (
|
||||
<Sun size={16} strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** The shell and the page-level helpers. */
|
||||
export { AppShell } from './AppShell.tsx'
|
||||
export { PageHeader } from './PageHeader.tsx'
|
||||
export { ThemeToggle } from './ThemeToggle.tsx'
|
||||
export { HealthPill } from './HealthPill.tsx'
|
||||
export { PageError } from './PageError.tsx'
|
||||
export { LoadingBlock } from './LoadingBlock.tsx'
|
||||
export type { Theme } from './ThemeToggle.tsx'
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export type BadgeTone = 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted'
|
||||
|
||||
export const BADGE_TONES: Record<BadgeTone, string> = {
|
||||
default: 'border-line bg-surface-3 text-fg',
|
||||
ok: 'border-transparent bg-ok-soft text-ok',
|
||||
warn: 'border-transparent bg-warn-soft text-warn',
|
||||
danger: 'border-transparent bg-danger-soft text-danger',
|
||||
info: 'border-transparent bg-info-soft text-info',
|
||||
muted: 'border-transparent bg-surface-3 text-fg-subtle',
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
tone = 'default',
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
tone?: BadgeTone
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex max-w-full items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs font-medium whitespace-nowrap',
|
||||
BADGE_TONES[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { Spinner } from './Spinner.tsx'
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline'
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon'
|
||||
|
||||
export const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-accent-strong text-accent-fg hover:brightness-110 active:brightness-95',
|
||||
secondary: 'bg-surface-3 text-fg hover:bg-line-strong border border-line',
|
||||
ghost: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
|
||||
danger: 'bg-danger text-white hover:brightness-110 active:brightness-95',
|
||||
outline: 'border border-line-strong text-fg hover:bg-surface-3',
|
||||
}
|
||||
|
||||
export const BUTTON_SIZES: Record<ButtonSize, string> = {
|
||||
sm: 'h-8 gap-1.5 px-2.5 text-xs',
|
||||
md: 'h-9 gap-2 px-3 text-sm',
|
||||
lg: 'h-11 gap-2 px-5 text-sm',
|
||||
icon: 'size-9 justify-center',
|
||||
}
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
loading?: boolean
|
||||
icon?: LucideIcon
|
||||
iconRight?: LucideIcon
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
icon: Icon,
|
||||
iconRight: IconRight,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
// A pending action must not be fireable twice: a double "Flush cache"
|
||||
// is a support ticket.
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded-lg font-medium whitespace-nowrap transition-colors',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
BUTTON_VARIANTS[variant],
|
||||
BUTTON_SIZES[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner size={size === 'lg' ? 'md' : 'sm'} />
|
||||
) : (
|
||||
Icon && <Icon size={size === 'sm' ? 14 : 16} strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
{children}
|
||||
{IconRight && !loading && (
|
||||
<IconRight size={size === 'sm' ? 14 : 16} strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function Card({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
padded = true,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
subtitle?: ReactNode
|
||||
actions?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
padded?: boolean
|
||||
}) {
|
||||
const hasHeader = title !== undefined || subtitle !== undefined || actions !== undefined
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col overflow-hidden rounded-xl border border-line bg-surface shadow-panel',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasHeader && (
|
||||
<CardHeader actions={actions}>
|
||||
{title !== undefined && <CardTitle subtitle={subtitle}>{title}</CardTitle>}
|
||||
{title === undefined && subtitle !== undefined && (
|
||||
<p className="text-sm text-fg-muted">{subtitle}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardBody padded={padded}>{children}</CardBody>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
subtitle?: ReactNode
|
||||
actions?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'flex flex-wrap items-start justify-between gap-3 border-b border-line bg-surface-2 px-4 py-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
{title !== undefined && <CardTitle subtitle={subtitle}>{title}</CardTitle>}
|
||||
{children}
|
||||
</div>
|
||||
{actions !== undefined && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
children,
|
||||
subtitle,
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
subtitle?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-w-0', className)}>
|
||||
<h2 className="truncate text-sm font-semibold text-fg">{children}</h2>
|
||||
{subtitle !== undefined && <p className="mt-0.5 text-xs text-fg-muted">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardBody({
|
||||
children,
|
||||
className,
|
||||
padded = true,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
padded?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex-1 min-w-0', padded && 'p-4', className)}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className }: { children?: ReactNode; className?: string }) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-4 py-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from './Button.tsx'
|
||||
import { Dialog } from './Dialog.tsx'
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
destructive = false,
|
||||
pending = false,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void | Promise<unknown>
|
||||
title: ReactNode
|
||||
message: ReactNode
|
||||
confirmLabel?: string
|
||||
destructive?: boolean
|
||||
pending?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={destructive ? 'danger' : 'primary'}
|
||||
onClick={() => void onConfirm()}
|
||||
loading={pending}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-fg-muted">{message}</p>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { IconButton } from './IconButton.tsx'
|
||||
|
||||
/** Copies a value (a rule, an address, a key) to the clipboard. */
|
||||
export function CopyButton({
|
||||
value,
|
||||
label = 'Copy',
|
||||
className,
|
||||
}: {
|
||||
value: string
|
||||
label?: string
|
||||
className?: string
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timer.current !== undefined) clearTimeout(timer.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copy = (): void => {
|
||||
const clipboard = navigator?.clipboard
|
||||
if (!clipboard) return
|
||||
void clipboard.writeText(value).then(
|
||||
() => {
|
||||
setCopied(true)
|
||||
if (timer.current !== undefined) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => setCopied(false), 1500)
|
||||
},
|
||||
() => {
|
||||
// A denied clipboard permission is not worth a toast; the button just
|
||||
// does not flip.
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={copied ? Check : Copy}
|
||||
label={copied ? 'Copied' : label}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={copy}
|
||||
className={cn(copied && 'text-ok', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useId, useRef } from 'react'
|
||||
import type { MouseEvent, ReactNode } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { IconButton } from './IconButton.tsx'
|
||||
|
||||
const SIZES = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Native `<dialog>`: focus trapping, inert background and Escape handling come
|
||||
* from the platform instead of a focus-trap library.
|
||||
*/
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
size = 'md',
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
children?: ReactNode
|
||||
footer?: ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}) {
|
||||
const ref = useRef<HTMLDialogElement>(null)
|
||||
const titleId = useId()
|
||||
const descId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
if (open && !el.open) {
|
||||
if (typeof el.showModal === 'function') el.showModal()
|
||||
else el.setAttribute('open', '')
|
||||
} else if (!open && el.open) {
|
||||
el.close()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// The element can be removed while open (route change): close it so the
|
||||
// top layer does not keep a detached node.
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
return () => {
|
||||
if (el?.open) el.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onBackdropClick = (event: MouseEvent<HTMLDialogElement>): void => {
|
||||
// Only a click on the dialog element itself is a backdrop click; clicks
|
||||
// inside the panel bubble up with a different target.
|
||||
if (event.target === ref.current) onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={description !== undefined ? descId : undefined}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}}
|
||||
onClose={onClose}
|
||||
onClick={onBackdropClick}
|
||||
className={cn(
|
||||
'm-auto w-[calc(100vw-2rem)] rounded-xl border border-line bg-surface p-0 text-fg shadow-panel backdrop:bg-black/60',
|
||||
'open:animate-slide-up',
|
||||
SIZES[size],
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 border-b border-line px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 id={titleId} className="text-sm font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
{description !== undefined && (
|
||||
<p id={descId} className="mt-0.5 text-xs text-fg-muted">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<IconButton icon={X} label="Close" onClick={onClose} />
|
||||
</div>
|
||||
<div className="max-h-[70vh] overflow-auto px-4 py-4 text-sm">{children}</div>
|
||||
{footer !== undefined && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-4 py-3">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Inbox } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon = Inbox,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: {
|
||||
icon?: LucideIcon
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-line px-6 py-12 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="grid size-11 place-items-center rounded-full bg-surface-3 text-fg-subtle">
|
||||
<Icon size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="max-w-md">
|
||||
<p className="text-sm font-medium text-fg">{title}</p>
|
||||
{description !== undefined && (
|
||||
<p className="mt-1 text-sm text-fg-muted">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action !== undefined && <div className="mt-1 flex items-center gap-2">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { BUTTON_SIZES, BUTTON_VARIANTS } from './Button.tsx'
|
||||
import type { ButtonSize, ButtonVariant } from './Button.tsx'
|
||||
import { Tooltip } from './Tooltip.tsx'
|
||||
|
||||
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'aria-label'> {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
tooltipSide?: 'top' | 'bottom' | 'left' | 'right'
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
variant = 'ghost',
|
||||
size = 'icon',
|
||||
tooltipSide = 'top',
|
||||
className,
|
||||
type = 'button',
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
return (
|
||||
<Tooltip content={label} side={tooltipSide}>
|
||||
<button
|
||||
type={type}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded-lg transition-colors',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
BUTTON_VARIANTS[variant],
|
||||
BUTTON_SIZES[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useId } from 'react'
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export const FIELD_BASE =
|
||||
'w-full rounded-lg border bg-surface-2 text-fg placeholder:text-fg-subtle transition-colors focus:outline-none focus-visible:border-accent disabled:opacity-60'
|
||||
|
||||
export function FieldFrame({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
label?: ReactNode
|
||||
hint?: ReactNode
|
||||
error?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{label !== undefined && (
|
||||
<label htmlFor={id} className="text-xs font-medium text-fg-muted">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{children}
|
||||
{error !== undefined && error !== null && error !== '' ? (
|
||||
<p id={`${id}-error`} role="alert" className="text-xs text-danger">
|
||||
{error}
|
||||
</p>
|
||||
) : hint !== undefined ? (
|
||||
<p id={`${id}-hint`} className="text-xs text-fg-subtle">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: ReactNode
|
||||
hint?: ReactNode
|
||||
error?: ReactNode
|
||||
icon?: LucideIcon
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Input({ label, hint, error, icon: Icon, className, id, ...props }: InputProps) {
|
||||
const autoId = useId()
|
||||
const inputId = id ?? autoId
|
||||
const describedBy = error ? `${inputId}-error` : hint !== undefined ? `${inputId}-hint` : undefined
|
||||
|
||||
const input = (
|
||||
<div className="relative">
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-fg-subtle"
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className={cn(
|
||||
FIELD_BASE,
|
||||
'h-9 px-2.5 text-sm',
|
||||
Icon && 'pl-8',
|
||||
error ? 'border-danger' : 'border-line',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (label === undefined && hint === undefined && error === undefined) return input
|
||||
return (
|
||||
<FieldFrame id={inputId} label={label} hint={hint} error={error}>
|
||||
{input}
|
||||
</FieldFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function Kbd({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<kbd
|
||||
className={cn(
|
||||
'inline-flex h-5 min-w-5 items-center justify-center rounded border border-line-strong bg-surface-3 px-1.5 font-mono text-[10px] font-medium text-fg-muted',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { Button } from './Button.tsx'
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
onPage,
|
||||
className,
|
||||
}: {
|
||||
page: number
|
||||
perPage: number
|
||||
total: number
|
||||
onPage: (page: number) => void
|
||||
className?: string
|
||||
}) {
|
||||
const size = perPage > 0 ? perPage : 1
|
||||
const pages = Math.max(1, Math.ceil(total / size))
|
||||
const current = Math.min(Math.max(1, page), pages)
|
||||
const first = (current - 1) * size + 1
|
||||
const last = Math.min(total, current * size)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center justify-between gap-3 text-xs text-fg-muted',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className="tabular">
|
||||
{total === 0 ? 'no rows' : `${first}–${last} of ${total.toLocaleString('en-US')}`}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={ChevronLeft}
|
||||
disabled={current <= 1}
|
||||
onClick={() => onPage(current - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
Prev
|
||||
</Button>
|
||||
<span className="tabular px-1">
|
||||
{current} / {pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
iconRight={ChevronRight}
|
||||
disabled={current >= pages}
|
||||
onClick={() => onPage(current + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export type ProgressTone = 'default' | 'ok' | 'warn' | 'danger' | 'info'
|
||||
|
||||
const TONES: Record<ProgressTone, string> = {
|
||||
default: 'bg-accent',
|
||||
ok: 'bg-ok',
|
||||
warn: 'bg-warn',
|
||||
danger: 'bg-danger',
|
||||
info: 'bg-info',
|
||||
}
|
||||
|
||||
export function Progress({
|
||||
value,
|
||||
max = 100,
|
||||
tone = 'default',
|
||||
className,
|
||||
showLabel = false,
|
||||
}: {
|
||||
value: number
|
||||
max?: number
|
||||
tone?: ProgressTone
|
||||
className?: string
|
||||
showLabel?: boolean
|
||||
}) {
|
||||
const safeMax = Number.isFinite(max) && max > 0 ? max : 100
|
||||
const safeValue = Number.isFinite(value) ? value : 0
|
||||
const pct = Math.max(0, Math.min(100, (safeValue / safeMax) * 100))
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full min-w-0 items-center gap-2', className)}>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(pct)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full min-w-0 overflow-hidden rounded-full bg-surface-3"
|
||||
>
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-[width] duration-300', TONES[tone])}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showLabel && (
|
||||
<span className="tabular w-9 shrink-0 text-right text-xs text-fg-muted">
|
||||
{pct.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function SectionTitle({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
className,
|
||||
}: {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-end justify-between gap-3', className)}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-fg">{title}</h2>
|
||||
{description !== undefined && (
|
||||
<p className="mt-0.5 text-xs text-fg-muted">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions !== undefined && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useId } from 'react'
|
||||
import type { ReactNode, SelectHTMLAttributes } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { FIELD_BASE, FieldFrame } from './Input.tsx'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: ReactNode
|
||||
hint?: ReactNode
|
||||
error?: ReactNode
|
||||
options: SelectOption[]
|
||||
className?: string
|
||||
selectClassName?: string
|
||||
}
|
||||
|
||||
export function Select({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
options,
|
||||
className,
|
||||
selectClassName,
|
||||
id,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const autoId = useId()
|
||||
const selectId = id ?? autoId
|
||||
const describedBy = error ? `${selectId}-error` : hint !== undefined ? `${selectId}-hint` : undefined
|
||||
|
||||
const select = (
|
||||
<div className="relative">
|
||||
<select
|
||||
id={selectId}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className={cn(
|
||||
FIELD_BASE,
|
||||
'h-9 appearance-none py-0 pr-8 pl-2.5 text-sm',
|
||||
error ? 'border-danger' : 'border-line',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-1/2 right-2.5 -translate-y-1/2 text-fg-subtle"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (label === undefined && hint === undefined && error === undefined) return select
|
||||
return (
|
||||
<FieldFrame id={selectId} label={label} hint={hint} error={error}>
|
||||
{select}
|
||||
</FieldFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
/** A shimmer placeholder used while a panel's data is in flight. */
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-md bg-surface-3',
|
||||
'after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/8 after:to-transparent',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
const SIZES = {
|
||||
sm: 'size-3 border',
|
||||
md: 'size-4 border-2',
|
||||
lg: 'size-6 border-2',
|
||||
} as const
|
||||
|
||||
export function Spinner({ size = 'md', className }: { size?: 'sm' | 'md' | 'lg'; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Loading"
|
||||
className={cn(
|
||||
'inline-block shrink-0 animate-spin rounded-full border-current border-t-transparent opacity-70',
|
||||
SIZES[size],
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { Skeleton } from './Skeleton.tsx'
|
||||
|
||||
export type StatTone = 'default' | 'ok' | 'warn' | 'danger'
|
||||
|
||||
const TONE_TEXT: Record<StatTone, string> = {
|
||||
default: 'text-fg',
|
||||
ok: 'text-ok',
|
||||
warn: 'text-warn',
|
||||
danger: 'text-danger',
|
||||
}
|
||||
|
||||
const TONE_ICON: Record<StatTone, string> = {
|
||||
default: 'bg-surface-3 text-fg-muted',
|
||||
ok: 'bg-ok-soft text-ok',
|
||||
warn: 'bg-warn-soft text-warn',
|
||||
danger: 'bg-danger-soft text-danger',
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
icon: Icon,
|
||||
trend,
|
||||
tone = 'default',
|
||||
className,
|
||||
loading,
|
||||
}: {
|
||||
label: ReactNode
|
||||
value: ReactNode
|
||||
hint?: ReactNode
|
||||
icon?: LucideIcon
|
||||
trend?: number
|
||||
tone?: StatTone
|
||||
className?: string
|
||||
loading?: boolean
|
||||
}) {
|
||||
const TrendIcon = trend === undefined || trend === 0 ? Minus : trend > 0 ? ArrowUpRight : ArrowDownRight
|
||||
// A rising block rate is good news; a rising error rate is not. The tone of
|
||||
// the card wins, otherwise fall back to "up is good".
|
||||
const trendTone = trend === undefined || trend === 0 ? 'text-fg-subtle' : (trend > 0) === (tone !== 'danger') ? 'text-ok' : 'text-danger'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 items-start gap-3 rounded-xl border border-line bg-surface p-4 shadow-panel',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{Icon && (
|
||||
<span className={cn('grid size-9 shrink-0 place-items-center rounded-lg', TONE_ICON[tone])}>
|
||||
<Icon size={18} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium tracking-wide text-fg-muted uppercase">{label}</p>
|
||||
{loading ? (
|
||||
<Skeleton className="mt-2 h-7 w-24" />
|
||||
) : (
|
||||
<p className={cn('tabular mt-1 truncate text-2xl font-semibold', TONE_TEXT[tone])}>{value}</p>
|
||||
)}
|
||||
{(hint !== undefined || trend !== undefined) && (
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2 text-xs">
|
||||
{trend !== undefined && (
|
||||
<span className={cn('tabular inline-flex items-center gap-0.5 font-medium', trendTone)}>
|
||||
<TrendIcon size={14} aria-hidden="true" />
|
||||
{Math.abs(trend).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
{hint !== undefined && <span className="truncate text-fg-subtle">{hint}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
className,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
label?: ReactNode
|
||||
description?: ReactNode
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const control = (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={typeof label === 'string' ? label : undefined}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
checked ? 'border-accent-strong bg-accent-strong' : 'border-line-strong bg-surface-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute size-3.5 rounded-full bg-white shadow-sm transition-transform',
|
||||
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
|
||||
if (label === undefined && description === undefined) return control
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-start justify-between gap-3',
|
||||
disabled ? 'opacity-60' : 'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
{label !== undefined && <span className="text-sm text-fg">{label}</span>}
|
||||
{description !== undefined && (
|
||||
<span className="text-xs text-fg-muted">{description}</span>
|
||||
)}
|
||||
</span>
|
||||
<span onClick={(e) => e.preventDefault()} className="mt-0.5 shrink-0">
|
||||
{control}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export type TableAlign = 'left' | 'right' | 'center'
|
||||
|
||||
export interface TableColumn {
|
||||
key: string
|
||||
header: ReactNode
|
||||
align?: TableAlign
|
||||
width?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ALIGN: Record<TableAlign, string> = {
|
||||
left: 'text-left',
|
||||
right: 'text-right',
|
||||
center: 'text-center',
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain data table: the caller owns the cell contents, so a cell is either a
|
||||
* ready-made node (a Badge, a switch) or a raw value it can format.
|
||||
*/
|
||||
export function Table({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
empty = 'Nothing to show',
|
||||
loading = false,
|
||||
onRowClick,
|
||||
maxHeight,
|
||||
className,
|
||||
}: {
|
||||
columns: TableColumn[]
|
||||
rows: Record<string, unknown>[]
|
||||
rowKey: (row: Record<string, unknown>) => string
|
||||
empty?: ReactNode
|
||||
loading?: boolean
|
||||
onRowClick?: (row: Record<string, unknown>) => void
|
||||
maxHeight?: number | string
|
||||
className?: string
|
||||
}) {
|
||||
const [hovered, setHovered] = useState<string | null>(null)
|
||||
|
||||
const maxHeightStyle =
|
||||
typeof maxHeight === 'number' ? { maxHeight: `${maxHeight}px` } : maxHeight ? { maxHeight } : undefined
|
||||
|
||||
if (rows.length === 0 && !loading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-lg border border-dashed border-line p-8 text-sm text-fg-subtle',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{empty}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative overflow-auto rounded-lg border border-line', className)}
|
||||
style={maxHeightStyle}
|
||||
>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-surface-2">
|
||||
<tr className="border-b border-line">
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
scope="col"
|
||||
style={column.width ? { width: column.width } : undefined}
|
||||
className={cn(
|
||||
'px-3 py-2 text-xs font-semibold tracking-wide text-fg-muted uppercase',
|
||||
ALIGN[column.align ?? 'left'],
|
||||
column.className,
|
||||
)}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const key = rowKey(row)
|
||||
const clickable = onRowClick !== undefined
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
onClick={clickable ? () => onRowClick(row) : undefined}
|
||||
onMouseEnter={clickable ? () => setHovered(key) : undefined}
|
||||
onMouseLeave={clickable ? () => setHovered(null) : undefined}
|
||||
className={cn(
|
||||
'border-b border-line last:border-0',
|
||||
clickable && 'cursor-pointer',
|
||||
hovered === key ? 'bg-surface-3' : 'hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const value = row[column.key]
|
||||
return (
|
||||
<td
|
||||
key={column.key}
|
||||
className={cn(
|
||||
'px-3 py-2 align-middle text-fg',
|
||||
ALIGN[column.align ?? 'left'],
|
||||
column.align === 'right' && 'tabular',
|
||||
column.className,
|
||||
)}
|
||||
>
|
||||
{value === null || value === undefined || value === ''
|
||||
? '—'
|
||||
: isRenderable(value)
|
||||
? value
|
||||
: String(value)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 grid place-items-center bg-surface/60 backdrop-blur-[1px]">
|
||||
<span className="rounded-md border border-line bg-surface-2 px-2 py-1 text-xs text-fg-muted">
|
||||
Loading…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** React elements and arrays render as-is; a plain object does not. */
|
||||
function isRenderable(value: unknown): value is ReactNode {
|
||||
if (Array.isArray(value)) return true
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return typeof (value as { $$typeof?: unknown }).$$typeof === 'symbol'
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export interface TabItem {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
icon?: LucideIcon
|
||||
}
|
||||
|
||||
export function Tabs({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
tabs: TabItem[]
|
||||
value: string
|
||||
onChange: (id: string) => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className={cn(
|
||||
'inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-lg border border-line bg-surface-2 p-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.id === value
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'bg-surface-3 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg',
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon size={14} strokeWidth={2} aria-hidden="true" />}
|
||||
{tab.label}
|
||||
{tab.count !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'tabular rounded px-1 py-px text-[10px]',
|
||||
active ? 'bg-accent-soft text-accent' : 'bg-surface-3 text-fg-subtle',
|
||||
)}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useId } from 'react'
|
||||
import type { ReactNode, TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { FIELD_BASE, FieldFrame } from './Input.tsx'
|
||||
|
||||
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: ReactNode
|
||||
hint?: ReactNode
|
||||
error?: ReactNode
|
||||
monospace?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
monospace = false,
|
||||
className,
|
||||
id,
|
||||
rows = 6,
|
||||
...props
|
||||
}: TextareaProps) {
|
||||
const autoId = useId()
|
||||
const fieldId = id ?? autoId
|
||||
const describedBy = error ? `${fieldId}-error` : hint !== undefined ? `${fieldId}-hint` : undefined
|
||||
|
||||
const textarea = (
|
||||
<textarea
|
||||
id={fieldId}
|
||||
rows={rows}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className={cn(
|
||||
FIELD_BASE,
|
||||
'resize-y px-2.5 py-2 text-sm leading-relaxed',
|
||||
monospace && 'font-mono text-xs',
|
||||
error ? 'border-danger' : 'border-line',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (label === undefined && hint === undefined && error === undefined) return textarea
|
||||
return (
|
||||
<FieldFrame id={fieldId} label={label} hint={hint} error={error}>
|
||||
{textarea}
|
||||
</FieldFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export type ToastTone = 'default' | 'ok' | 'warn' | 'danger'
|
||||
|
||||
export interface ToastOptions {
|
||||
title: string
|
||||
description?: string
|
||||
tone?: ToastTone
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
interface ToastItem extends ToastOptions {
|
||||
id: number
|
||||
tone: ToastTone
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: ToastItem[]
|
||||
toast: (options: ToastOptions) => void
|
||||
dismiss: (id: number) => void
|
||||
claimToaster: () => boolean
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
const TONE_STYLES: Record<ToastTone, string> = {
|
||||
default: 'border-line bg-surface-3',
|
||||
ok: 'border-ok/40 bg-ok-soft',
|
||||
warn: 'border-warn/40 bg-warn-soft',
|
||||
danger: 'border-danger/40 bg-danger-soft',
|
||||
}
|
||||
|
||||
const TONE_ICON: Record<ToastTone, typeof Info> = {
|
||||
default: Info,
|
||||
ok: CheckCircle2,
|
||||
warn: AlertTriangle,
|
||||
danger: XCircle,
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>())
|
||||
const claimed = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const pending = timers.current
|
||||
return () => {
|
||||
for (const timer of pending.values()) clearTimeout(timer)
|
||||
pending.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
const timer = timers.current.get(id)
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
timers.current.delete(id)
|
||||
}
|
||||
setToasts((current) => current.filter((item) => item.id !== id))
|
||||
}, [])
|
||||
|
||||
const toast = useCallback(
|
||||
(options: ToastOptions) => {
|
||||
const item: ToastItem = {
|
||||
tone: 'default',
|
||||
durationMs: 5000,
|
||||
...options,
|
||||
id: nextId++,
|
||||
}
|
||||
// Cap the stack: a failing refresh loop must not bury the screen.
|
||||
setToasts((current) => [...current, item].slice(-5))
|
||||
if (item.durationMs > 0) {
|
||||
timers.current.set(item.id, setTimeout(() => dismiss(item.id), item.durationMs))
|
||||
}
|
||||
},
|
||||
[dismiss],
|
||||
)
|
||||
|
||||
const claimToaster = useCallback(() => {
|
||||
if (claimed.current) return false
|
||||
claimed.current = true
|
||||
return true
|
||||
}, [])
|
||||
|
||||
const value = useMemo<ToastContextValue>(
|
||||
() => ({ toasts, toast, dismiss, claimToaster }),
|
||||
[toasts, toast, dismiss, claimToaster],
|
||||
)
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const NOOP = {
|
||||
toast: () => {},
|
||||
dismiss: () => {},
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires a toast. Outside a provider it degrades to a no-op instead of
|
||||
* throwing: a notification is never a reason for a page to fail to render.
|
||||
*/
|
||||
export function useToast(): { toast: (options: ToastOptions) => void } {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (ctx === null) return NOOP
|
||||
return { toast: ctx.toast }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the stack bottom-right in a portal on <body>. The provider already
|
||||
* mounts one; a hand-placed extra <Toaster/> is ignored so the DOM never
|
||||
* doubles up.
|
||||
*/
|
||||
export function Toaster() {
|
||||
const ctx = useContext(ToastContext)
|
||||
const [owns] = useState(() => (ctx === null ? false : ctx.claimToaster()))
|
||||
|
||||
if (ctx === null || !owns || ctx.toasts.length === 0) return null
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="pointer-events-none fixed right-4 bottom-4 z-[100] flex w-[min(24rem,calc(100vw-2rem))] flex-col gap-2"
|
||||
>
|
||||
{ctx.toasts.map((item) => {
|
||||
const Icon = TONE_ICON[item.tone]
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'animate-slide-up pointer-events-auto flex items-start gap-2.5 rounded-lg border p-3 shadow-panel',
|
||||
TONE_STYLES[item.tone],
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
className="mt-0.5 shrink-0 text-fg-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-fg">{item.title}</p>
|
||||
{item.description !== undefined && (
|
||||
<p className="mt-0.5 text-xs break-words text-fg-muted">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss notification"
|
||||
onClick={() => ctx.dismiss(item.id)}
|
||||
className="shrink-0 rounded-md p-0.5 text-fg-subtle transition-colors hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<X size={14} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
const SIDES = {
|
||||
top: 'bottom-full left-1/2 -translate-x-1/2 mb-1.5',
|
||||
bottom: 'top-full left-1/2 -translate-x-1/2 mt-1.5',
|
||||
left: 'right-full top-1/2 -translate-y-1/2 mr-1.5',
|
||||
right: 'left-full top-1/2 -translate-y-1/2 ml-1.5',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Hover/focus tooltip built from one wrapper element. No positioning library
|
||||
* and no portal: the console has no tooltip that needs to escape an
|
||||
* overflow container, and CSS keeps it working in a screenshot test.
|
||||
*/
|
||||
export function Tooltip({
|
||||
content,
|
||||
children,
|
||||
side = 'top',
|
||||
className,
|
||||
}: {
|
||||
content: ReactNode
|
||||
children: ReactNode
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
className?: string
|
||||
}) {
|
||||
if (content === undefined || content === null || content === '') return <>{children}</>
|
||||
return (
|
||||
<span className={cn('group/tt relative inline-flex', className)}>
|
||||
{children}
|
||||
<span
|
||||
role="tooltip"
|
||||
className={cn(
|
||||
'pointer-events-none absolute z-50 hidden w-max max-w-64 rounded-md border border-line bg-surface-3 px-2 py-1 text-xs font-normal text-fg shadow-panel',
|
||||
'animate-fade-in group-hover/tt:block group-focus-within/tt:block',
|
||||
SIDES[side],
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Every primitive the pages are allowed to reach for. */
|
||||
export { Card, CardBody, CardFooter, CardHeader, CardTitle } from './Card.tsx'
|
||||
export { StatCard } from './StatCard.tsx'
|
||||
export { Button } from './Button.tsx'
|
||||
export { IconButton } from './IconButton.tsx'
|
||||
export { Input } from './Input.tsx'
|
||||
export { Textarea } from './Textarea.tsx'
|
||||
export { Select } from './Select.tsx'
|
||||
export { Switch } from './Switch.tsx'
|
||||
export { Badge } from './Badge.tsx'
|
||||
export { Table } from './Table.tsx'
|
||||
export { Dialog } from './Dialog.tsx'
|
||||
export { ConfirmDialog } from './ConfirmDialog.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export { ToastProvider, Toaster, useToast } from './Toast.tsx'
|
||||
export { Skeleton } from './Skeleton.tsx'
|
||||
export { EmptyState } from './EmptyState.tsx'
|
||||
export { Tabs } from './Tabs.tsx'
|
||||
export { Spinner } from './Spinner.tsx'
|
||||
export { Progress } from './Progress.tsx'
|
||||
export { Pagination } from './Pagination.tsx'
|
||||
export { Kbd } from './Kbd.tsx'
|
||||
export { CopyButton } from './CopyButton.tsx'
|
||||
export { SectionTitle } from './SectionTitle.tsx'
|
||||
|
||||
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button.tsx'
|
||||
export type { InputProps } from './Input.tsx'
|
||||
export type { TextareaProps } from './Textarea.tsx'
|
||||
export type { SelectOption, SelectProps } from './Select.tsx'
|
||||
export type { TabItem } from './Tabs.tsx'
|
||||
export type { TableColumn } from './Table.tsx'
|
||||
export type { ToastOptions, ToastTone } from './Toast.tsx'
|
||||
export type { BadgeTone } from './Badge.tsx'
|
||||
export type { IconButtonProps } from './IconButton.tsx'
|
||||
export type { StatTone } from './StatCard.tsx'
|
||||
export type { ProgressTone } from './Progress.tsx'
|
||||
Reference in new issue
Block a user