feat: base UI components, layout and global styles
This commit is contained in:
241
src/components/ui.tsx
Normal file
241
src/components/ui.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, type ReactNode } from 'react';
|
||||
|
||||
// ─── Collapsible Section ────────────────────────────────────────────────────
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
badge?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function CollapsibleSection({
|
||||
title,
|
||||
icon,
|
||||
defaultOpen = true,
|
||||
badge,
|
||||
children,
|
||||
}: CollapsibleSectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<button
|
||||
className="card-header w-full"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && <span className="text-slate-400">{icon}</span>}
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">{title}</h3>
|
||||
{badge}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 transition-transform duration-200 ${
|
||||
open ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && <div className="card-body animate-fade-in">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Number Input ───────────────────────────────────────────────────────────
|
||||
|
||||
interface NumberInputProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
suffix?: string;
|
||||
prefix?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
hint?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NumberInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
suffix,
|
||||
prefix,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
hint,
|
||||
className,
|
||||
}: NumberInputProps) {
|
||||
return (
|
||||
<div className={`form-field ${className ?? ''}`}>
|
||||
<label className="form-label">{label}</label>
|
||||
<div className="relative">
|
||||
{prefix && (
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400 pointer-events-none">
|
||||
{prefix}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="number"
|
||||
className={`form-input-sm ${prefix ? 'pl-7' : ''} ${suffix ? 'pr-10' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
/>
|
||||
{suffix && (
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-slate-400 pointer-events-none">
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hint && <p className="text-xs text-slate-400 mt-0.5">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Rate Input (special for percentages) ───────────────────────────────────
|
||||
|
||||
interface RateInputProps {
|
||||
label: string;
|
||||
value: number; // stored as decimal (0.0325 for 3.25%)
|
||||
onChange: (v: number) => void;
|
||||
hint?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RateInput({ label, value, onChange, hint, className }: RateInputProps) {
|
||||
const displayValue = Math.round(value * 10000) / 100; // 0.0325 → 3.25
|
||||
return (
|
||||
<div className={`form-field ${className ?? ''}`}>
|
||||
<label className="form-label">{label}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
className="form-input-sm pr-8"
|
||||
value={displayValue}
|
||||
onChange={(e) => onChange((parseFloat(e.target.value) || 0) / 100)}
|
||||
step={0.01}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-slate-400 pointer-events-none">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
{hint && <p className="text-xs text-slate-400 mt-0.5">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Toggle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ToggleProps {
|
||||
options: { value: string; label: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Toggle({ options, value, onChange, className }: ToggleProps) {
|
||||
return (
|
||||
<div className={`inline-flex rounded-lg bg-slate-100 dark:bg-slate-800 p-0.5 ${className ?? ''}`}>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all ${
|
||||
value === opt.value
|
||||
? 'bg-white dark:bg-slate-700 text-blue-700 dark:text-blue-400 shadow-sm'
|
||||
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
|
||||
}`}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tabs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TabsProps {
|
||||
tabs: { id: string; label: string; icon?: ReactNode }[];
|
||||
activeTab: string;
|
||||
onTabChange: (id: string) => void;
|
||||
}
|
||||
|
||||
export function Tabs({ tabs, activeTab, onTabChange }: TabsProps) {
|
||||
return (
|
||||
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-800/50 rounded-xl overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-btn whitespace-nowrap flex items-center gap-1.5 ${
|
||||
activeTab === tab.id ? 'tab-btn-active' : ''
|
||||
}`}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tooltip ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TooltipProps {
|
||||
text: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Tooltip({ text, children }: TooltipProps) {
|
||||
return (
|
||||
<span className="relative group">
|
||||
{children}
|
||||
<span
|
||||
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1.5
|
||||
bg-slate-800 text-white text-xs rounded-lg whitespace-nowrap
|
||||
opacity-0 invisible group-hover:opacity-100 group-hover:visible
|
||||
transition-all duration-200 pointer-events-none z-50
|
||||
after:content-[''] after:absolute after:top-full after:left-1/2
|
||||
after:-translate-x-1/2 after:border-4 after:border-transparent
|
||||
after:border-t-slate-800"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Empty State ────────────────────────────────────────────────────────────
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="text-center py-12 px-4">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{title}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{description}</p>
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user