feat(analysis): 增强图表交互功能

- 分类支出排行: 饼图支持点击类别切换显示/隐藏,百分比动态重新计算
- 每日支出趋势: 图例支持点击切换类别显示,隐藏类别不参与堆叠计算
- Dialog列表: 添加列排序功能(时间/商家/描述/金额)
- Dialog列表: 添加分页功能,每页10条(分类)/8条(每日)
- 饼图hover效果: 扇形放大、阴影增强、中心显示详情
This commit is contained in:
clz
2026-01-08 02:55:54 +08:00
parent c40a118a3d
commit 9d409d6a93
161 changed files with 9155 additions and 0 deletions

128
web/src/app.css Normal file
View File

@@ -0,0 +1,128 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
/* 移除图表元素的 focus outline */
svg *:focus,
svg *:focus-visible,
[data-layerchart] *:focus,
[data-layerchart] *:focus-visible {
outline: none !important;
}
}

13
web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
web/src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

7
web/src/demo.spec.ts Normal file
View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

164
web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,164 @@
// API 配置
const API_BASE = 'http://localhost:8080';
// 类型定义
export interface UploadData {
bill_type: 'alipay' | 'wechat';
file_url: string;
file_name: string;
}
export interface UploadResponse {
result: boolean;
message: string;
data?: UploadData;
}
export interface ReviewRecord {
time: string;
category: string;
merchant: string;
description: string;
income_expense: string;
amount: string;
remark: string;
review_level: 'HIGH' | 'LOW';
}
export interface ReviewData {
total: number;
high: number;
low: number;
records: ReviewRecord[];
}
export interface ReviewResponse {
result: boolean;
message: string;
data?: ReviewData;
}
export interface BillRecord {
time: string;
category: string;
merchant: string;
description: string;
income_expense: string;
amount: string;
payment_method: string;
status: string;
remark: string;
needs_review: string;
}
// 上传账单
export async function uploadBill(file: File, options?: { year?: number; month?: number }): Promise<UploadResponse> {
const formData = new FormData();
formData.append('file', file);
if (options?.year) {
formData.append('year', options.year.toString());
}
if (options?.month) {
formData.append('month', options.month.toString());
}
const response = await fetch(`${API_BASE}/api/upload`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
// 获取复核记录
export async function getReviewRecords(fileName: string): Promise<ReviewResponse> {
const response = await fetch(`${API_BASE}/api/review?file=${encodeURIComponent(fileName)}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
// 下载文件 URL
export function getDownloadUrl(fileUrl: string): string {
return `${API_BASE}${fileUrl}`;
}
// 解析账单内容(用于前端展示全部记录)
export async function fetchBillContent(fileName: string): Promise<BillRecord[]> {
const response = await fetch(`${API_BASE}/download/${fileName}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const text = await response.text();
return parseCSV(text);
}
// 解析 CSV
function parseCSV(text: string): BillRecord[] {
const lines = text.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',');
const records: BillRecord[] = [];
for (let i = 1; i < lines.length; i++) {
const values = parseCSVLine(lines[i]);
if (values.length >= headers.length) {
records.push({
time: values[0] || '',
category: values[1] || '',
merchant: values[2] || '',
description: values[3] || '',
income_expense: values[4] || '',
amount: values[5] || '',
payment_method: values[6] || '',
status: values[7] || '',
remark: values[8] || '',
needs_review: values[9] || '',
});
}
}
return records;
}
// 解析 CSV 行(处理引号)
function parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current);
current = '';
} else {
current += char;
}
}
result.push(current);
return result;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,408 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import PieChartIcon from '@lucide/svelte/icons/pie-chart';
import ListIcon from '@lucide/svelte/icons/list';
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import X from '@lucide/svelte/icons/x';
import type { CategoryStat, PieChartDataItem } from '$lib/types/analysis';
import type { BillRecord } from '$lib/api';
import { getPercentage } from '$lib/services/analysis';
import { barColors } from '$lib/constants/chart';
interface Props {
categoryStats: CategoryStat[];
pieChartData: PieChartDataItem[];
totalExpense: number;
records: BillRecord[];
}
let { categoryStats, pieChartData, totalExpense, records }: Props = $props();
let mode = $state<'bar' | 'pie'>('bar');
let dialogOpen = $state(false);
let selectedCategory = $state<string | null>(null);
let hoveredPieIndex = $state<number | null>(null);
// 隐藏的类别
let hiddenCategories = $state<Set<string>>(new Set());
function toggleCategory(category: string) {
const newSet = new Set(hiddenCategories);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
hiddenCategories = newSet;
}
// 过滤后的饼图数据(排除隐藏的类别)
let filteredPieChartData = $derived.by(() => {
const visible = pieChartData.filter(item => !hiddenCategories.has(item.category));
const total = visible.reduce((sum, item) => sum + item.value, 0);
// 重新计算百分比
return visible.map(item => ({
...item,
percentage: total > 0 ? (item.value / total) * 100 : 0
}));
});
// 过滤后的总支出
let filteredTotalExpense = $derived(
filteredPieChartData.reduce((sum, item) => sum + item.value, 0)
);
// 排序状态
type SortField = 'time' | 'merchant' | 'description' | 'amount';
type SortOrder = 'asc' | 'desc';
let sortField = $state<SortField>('time');
let sortOrder = $state<SortOrder>('desc');
// 分页状态
let currentPage = $state(1);
const pageSize = 10; // 每页条目数
let expenseStats = $derived(categoryStats.filter(s => s.expense > 0));
// 获取选中分类的账单记录(已排序)
let selectedRecords = $derived.by(() => {
if (!selectedCategory) return [];
const filtered = records.filter(r => r.category === selectedCategory && r.income_expense === '支出');
return filtered.toSorted((a, b) => {
let cmp = 0;
switch (sortField) {
case 'time':
cmp = new Date(a.time).getTime() - new Date(b.time).getTime();
break;
case 'merchant':
cmp = a.merchant.localeCompare(b.merchant);
break;
case 'description':
cmp = (a.description || '').localeCompare(b.description || '');
break;
case 'amount':
cmp = parseFloat(a.amount) - parseFloat(b.amount);
break;
}
return sortOrder === 'asc' ? cmp : -cmp;
});
});
// 分页计算
let totalPages = $derived(Math.ceil(selectedRecords.length / pageSize));
let paginatedRecords = $derived(
selectedRecords.slice((currentPage - 1) * pageSize, currentPage * pageSize)
);
// 选中分类的统计
let selectedStat = $derived(
selectedCategory ? categoryStats.find(s => s.category === selectedCategory) : null
);
function openCategoryDetail(category: string) {
selectedCategory = category;
sortField = 'time';
sortOrder = 'desc';
currentPage = 1;
dialogOpen = true;
}
function toggleSort(field: SortField) {
if (sortField === field) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortOrder = field === 'amount' ? 'desc' : 'asc';
}
currentPage = 1; // 排序后重置到第一页
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
}
}
</script>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between pb-2">
<div class="space-y-1.5">
<Card.Title class="flex items-center gap-2">
<PieChartIcon class="h-5 w-5" />
分类支出排行
</Card.Title>
<Card.Description>各分类支出金额占比(点击查看详情)</Card.Description>
</div>
<div class="flex gap-1">
<Button
variant={mode === 'bar' ? 'default' : 'outline'}
size="icon"
class="h-8 w-8"
onclick={() => mode = 'bar'}
>
<ListIcon class="h-4 w-4" />
</Button>
<Button
variant={mode === 'pie' ? 'default' : 'outline'}
size="icon"
class="h-8 w-8"
onclick={() => mode = 'pie'}
>
<PieChartIcon class="h-4 w-4" />
</Button>
</div>
</Card.Header>
<Card.Content>
{#if mode === 'bar'}
<!-- 条形图视图 -->
<div class="space-y-1">
{#each expenseStats as stat, i}
<button
class="w-full text-left space-y-1 p-2 rounded-lg hover:bg-accent/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 cursor-pointer outline-none focus:outline-none"
onclick={() => openCategoryDetail(stat.category)}
>
<div class="flex items-center justify-between text-sm">
<span class="font-medium">{stat.category}</span>
<span class="font-mono">¥{stat.expense.toFixed(2)}</span>
</div>
<div class="h-2 rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500 {barColors[i % barColors.length]}"
style="width: {getPercentage(stat.expense, totalExpense)}%"
></div>
</div>
<div class="flex justify-between text-xs text-muted-foreground">
<span>{stat.count}</span>
<span>{getPercentage(stat.expense, totalExpense).toFixed(1)}%</span>
</div>
</button>
{/each}
</div>
{:else}
<!-- 饼状图视图 -->
<div class="flex flex-col items-center">
<div class="relative">
<svg width="220" height="220" viewBox="-110 -110 220 220">
{#each filteredPieChartData as item, i}
{@const total = filteredPieChartData.reduce((sum, d) => sum + d.value, 0)}
{@const startAngle = filteredPieChartData.slice(0, i).reduce((sum, d) => sum + (d.value / total) * Math.PI * 2, 0) - Math.PI / 2}
{@const endAngle = startAngle + (item.value / total) * Math.PI * 2}
{@const innerRadius = 45}
{@const outerRadius = hoveredPieIndex === i ? 95 : 90}
{@const x1 = Math.cos(startAngle) * outerRadius}
{@const y1 = Math.sin(startAngle) * outerRadius}
{@const x2 = Math.cos(endAngle) * outerRadius}
{@const y2 = Math.sin(endAngle) * outerRadius}
{@const x3 = Math.cos(endAngle) * innerRadius}
{@const y3 = Math.sin(endAngle) * innerRadius}
{@const x4 = Math.cos(startAngle) * innerRadius}
{@const y4 = Math.sin(startAngle) * innerRadius}
{@const largeArc = (endAngle - startAngle) > Math.PI ? 1 : 0}
<path
d="M {x1} {y1} A {outerRadius} {outerRadius} 0 {largeArc} 1 {x2} {y2} L {x3} {y3} A {innerRadius} {innerRadius} 0 {largeArc} 0 {x4} {y4} Z"
fill={item.color}
class="transition-all duration-200 cursor-pointer outline-none focus:outline-none"
style="transform-origin: center; filter: drop-shadow(0 2px 4px rgba(0,0,0,{hoveredPieIndex === i ? '0.3' : '0.15'})); opacity: {hoveredPieIndex !== null && hoveredPieIndex !== i ? '0.6' : '1'};"
onclick={() => openCategoryDetail(item.category)}
onmouseenter={() => hoveredPieIndex = i}
onmouseleave={() => hoveredPieIndex = null}
role="button"
tabindex="-1"
/>
{/each}
<!-- 中心文字 -->
{#if hoveredPieIndex !== null && filteredPieChartData[hoveredPieIndex]}
{@const hovered = filteredPieChartData[hoveredPieIndex]}
<text x="0" y="-12" text-anchor="middle" class="fill-foreground text-xs font-medium">{hovered.category}</text>
<text x="0" y="6" text-anchor="middle" class="fill-foreground text-sm font-bold font-mono">¥{hovered.value.toFixed(0)}</text>
<text x="0" y="22" text-anchor="middle" class="fill-muted-foreground text-xs">{hovered.percentage.toFixed(1)}%</text>
{:else}
<text x="0" y="-8" text-anchor="middle" class="fill-foreground text-sm font-medium">总支出</text>
<text x="0" y="12" text-anchor="middle" class="fill-foreground text-lg font-bold font-mono">¥{filteredTotalExpense.toFixed(0)}</text>
{/if}
</svg>
</div>
<!-- 图例 (点击可切换显示) -->
<div class="mt-4 grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
{#each pieChartData as item}
{@const isHidden = hiddenCategories.has(item.category)}
{@const filteredItem = filteredPieChartData.find(f => f.category === item.category)}
{@const displayPercentage = isHidden ? item.percentage : (filteredItem?.percentage ?? 0)}
<button
class="flex items-center gap-2 px-2 py-1 -mx-1 cursor-pointer hover:opacity-80 transition-opacity outline-none"
onclick={() => toggleCategory(item.category)}
title={isHidden ? '点击显示' : '点击隐藏'}
>
<span
class="h-3 w-3 rounded-sm shrink-0 transition-opacity {isHidden ? 'opacity-30' : ''}"
style="background-color: {item.color}"
></span>
<span class="truncate transition-colors {isHidden ? 'text-muted-foreground/40 line-through' : 'text-muted-foreground'}">{item.category}</span>
<span class="font-mono font-medium ml-auto transition-colors {isHidden ? 'text-muted-foreground/40' : ''}">{displayPercentage.toFixed(1)}%</span>
</button>
{/each}
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- 分类详情弹窗 -->
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="w-fit min-w-[500px] max-w-[90vw] max-h-[80vh] overflow-hidden flex flex-col">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<PieChartIcon class="h-5 w-5" />
{selectedCategory} - 账单明细
</Dialog.Title>
<Dialog.Description>
{#if selectedStat}
{selectedStat.count} 笔,合计 ¥{selectedStat.expense.toFixed(2)}
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="flex-1 overflow-auto mt-4">
{#if selectedRecords.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[140px]">
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('time')}
>
时间
{#if sortField === 'time'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
<Table.Head>
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('merchant')}
>
商家
{#if sortField === 'merchant'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
<Table.Head>
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('description')}
>
描述
{#if sortField === 'description'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
<Table.Head class="text-right w-[100px]">
<button
class="flex items-center gap-1 ml-auto hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('amount')}
>
金额
{#if sortField === 'amount'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedRecords as record}
<Table.Row>
<Table.Cell class="text-muted-foreground text-xs">
{record.time.substring(0, 16)}
</Table.Cell>
<Table.Cell class="font-medium">{record.merchant}</Table.Cell>
<Table.Cell class="text-muted-foreground truncate max-w-[200px]">
{record.description || '-'}
</Table.Cell>
<Table.Cell class="text-right font-mono text-red-600 dark:text-red-400">
¥{record.amount}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<!-- 分页控件 -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-4 pt-4 border-t">
<div class="text-sm text-muted-foreground">
显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, selectedRecords.length)} 条,共 {selectedRecords.length}
</div>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="icon"
class="h-8 w-8"
disabled={currentPage === 1}
onclick={() => goToPage(currentPage - 1)}
>
<ChevronLeft class="h-4 w-4" />
</Button>
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
<Button
variant={page === currentPage ? 'default' : 'outline'}
size="icon"
class="h-8 w-8"
onclick={() => goToPage(page)}
>
{page}
</Button>
{:else if page === currentPage - 2 || page === currentPage + 2}
<span class="px-1 text-muted-foreground">...</span>
{/if}
{/each}
<Button
variant="outline"
size="icon"
class="h-8 w-8"
disabled={currentPage === totalPages}
onclick={() => goToPage(currentPage + 1)}
>
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</div>
{/if}
{:else}
<div class="text-center py-8 text-muted-foreground">
暂无记录
</div>
{/if}
</div>
<Dialog.Footer class="mt-4">
<Button variant="outline" onclick={() => dialogOpen = false}>
关闭
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,788 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import * as Dialog from '$lib/components/ui/dialog';
import Activity from '@lucide/svelte/icons/activity';
import TrendingUp from '@lucide/svelte/icons/trending-up';
import TrendingDown from '@lucide/svelte/icons/trending-down';
import Calendar from '@lucide/svelte/icons/calendar';
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import { Button } from '$lib/components/ui/button';
import type { BillRecord } from '$lib/api';
import { pieColors } from '$lib/constants/chart';
interface Props {
records: BillRecord[];
}
let { records }: Props = $props();
// Dialog 状态
let dialogOpen = $state(false);
let selectedDate = $state<Date | null>(null);
let selectedDateRecords = $state<BillRecord[]>([]);
// 排序状态
type SortField = 'time' | 'category' | 'merchant' | 'amount';
type SortOrder = 'asc' | 'desc';
let sortField = $state<SortField>('amount');
let sortOrder = $state<SortOrder>('desc');
// 分页状态
let currentPage = $state(1);
const pageSize = 8; // 每页条目数
// 排序后的记录
let sortedDateRecords = $derived.by(() => {
return selectedDateRecords.toSorted((a, b) => {
let cmp = 0;
switch (sortField) {
case 'time':
cmp = a.time.localeCompare(b.time);
break;
case 'category':
cmp = a.category.localeCompare(b.category);
break;
case 'merchant':
cmp = a.merchant.localeCompare(b.merchant);
break;
case 'amount':
cmp = parseFloat(a.amount) - parseFloat(b.amount);
break;
}
return sortOrder === 'asc' ? cmp : -cmp;
});
});
// 分页计算
let totalPages = $derived(Math.ceil(sortedDateRecords.length / pageSize));
let paginatedRecords = $derived(
sortedDateRecords.slice((currentPage - 1) * pageSize, currentPage * pageSize)
);
function toggleSort(field: SortField) {
if (sortField === field) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortOrder = field === 'amount' ? 'desc' : 'asc';
}
currentPage = 1; // 排序后重置到第一页
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
}
}
// 时间范围选项
type TimeRange = '7d' | '30d' | '3m';
let timeRange = $state<TimeRange>('3m');
// 隐藏的类别
let hiddenCategories = $state<Set<string>>(new Set());
function toggleCategory(category: string) {
const newSet = new Set(hiddenCategories);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
hiddenCategories = newSet;
}
const timeRangeOptions = [
{ value: '7d', label: '最近 7 天' },
{ value: '30d', label: '最近 30 天' },
{ value: '3m', label: '最近 3 个月' }
];
// 获取截止日期
function getCutoffDate(range: TimeRange): Date {
const now = new Date();
switch (range) {
case '7d':
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
case '30d':
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
case '3m':
default:
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
}
}
// 按日期和分类分组数据
let processedData = $derived(() => {
const cutoffDate = getCutoffDate(timeRange);
// 过滤支出记录
const expenseRecords = records.filter(r => {
if (r.income_expense !== '支出') return false;
const recordDate = new Date(r.time.split(' ')[0]);
return recordDate >= cutoffDate;
});
if (expenseRecords.length === 0) return { data: [], categories: [], maxValue: 0 };
// 按日期分组
const dateMap = new Map<string, Map<string, number>>();
const categorySet = new Set<string>();
const categoryTotals: Record<string, number> = {};
expenseRecords.forEach(record => {
const dateStr = record.time.split(' ')[0];
const category = record.category || '其他';
const amount = parseFloat(record.amount) || 0;
categorySet.add(category);
categoryTotals[category] = (categoryTotals[category] || 0) + amount;
if (!dateMap.has(dateStr)) {
dateMap.set(dateStr, new Map());
}
const dayData = dateMap.get(dateStr)!;
dayData.set(category, (dayData.get(category) || 0) + amount);
});
// 获取前5大分类
const sortedCategories = Object.entries(categoryTotals)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([cat]) => cat);
// 转换为数组格式并计算堆叠值
const data = Array.from(dateMap.entries())
.map(([dateStr, dayData]) => {
const result: Record<string, any> = {
date: new Date(dateStr),
dateStr: dateStr
};
let cumulative = 0;
sortedCategories.forEach(cat => {
const value = dayData.get(cat) || 0;
result[cat] = value;
// 只有未隐藏的类别参与堆叠
if (!hiddenCategories.has(cat)) {
result[`${cat}_y0`] = cumulative;
result[`${cat}_y1`] = cumulative + value;
cumulative += value;
} else {
result[`${cat}_y0`] = 0;
result[`${cat}_y1`] = 0;
}
});
// 其他分类汇总
let otherSum = 0;
dayData.forEach((amount, cat) => {
if (!sortedCategories.includes(cat)) {
otherSum += amount;
}
});
if (otherSum > 0) {
result['其他'] = otherSum;
if (!hiddenCategories.has('其他')) {
result['其他_y0'] = cumulative;
result['其他_y1'] = cumulative + otherSum;
cumulative += otherSum;
} else {
result['其他_y0'] = 0;
result['其他_y1'] = 0;
}
}
result.total = cumulative;
return result;
})
.sort((a, b) => a.date.getTime() - b.date.getTime());
const finalCategories = [...sortedCategories];
if (data.some(d => d['其他'] > 0)) {
finalCategories.push('其他');
}
const maxValue = Math.max(...data.map(d => d.total || 0), 1);
return { data, categories: finalCategories, maxValue };
});
// 获取颜色
function getColor(index: number): string {
return pieColors[index % pieColors.length];
}
// 趋势计算
let trendInfo = $derived(() => {
const { data } = processedData();
if (data.length < 2) return null;
const lastTotal = data[data.length - 1].total || 0;
const prevTotal = data[data.length - 2].total || 0;
const change = lastTotal - prevTotal;
const changePercent = prevTotal > 0 ? (change / prevTotal) * 100 : 0;
return { change, changePercent, lastTotal };
});
// 获取描述文本
let descriptionText = $derived(() => {
const { data } = processedData();
const label = timeRangeOptions.find(o => o.value === timeRange)?.label || '最近 3 个月';
return `${label}各分类支出趋势 (${data.length} 天)`;
});
function handleTimeRangeChange(value: string | undefined) {
if (value && ['7d', '30d', '3m'].includes(value)) {
timeRange = value as TimeRange;
}
}
// SVG 尺寸
const chartWidth = 800;
const chartHeight = 250;
const padding = { top: 20, right: 20, bottom: 40, left: 60 };
const innerWidth = chartWidth - padding.left - padding.right;
const innerHeight = chartHeight - padding.top - padding.bottom;
// 坐标转换
function xScale(date: Date, data: any[]): number {
if (data.length <= 1) return padding.left;
const minDate = data[0].date.getTime();
const maxDate = data[data.length - 1].date.getTime();
const range = maxDate - minDate || 1;
return padding.left + ((date.getTime() - minDate) / range) * innerWidth;
}
function yScale(value: number, maxValue: number): number {
if (maxValue === 0) return padding.top + innerHeight;
return padding.top + innerHeight - (value / maxValue) * innerHeight;
}
// 生成面积路径
function generateAreaPath(category: string, data: any[], maxValue: number): string {
if (data.length === 0) return '';
const points: string[] = [];
const bottomPoints: string[] = [];
data.forEach((d, i) => {
const x = xScale(d.date, data);
const y1 = yScale(d[`${category}_y1`] || 0, maxValue);
const y0 = yScale(d[`${category}_y0`] || 0, maxValue);
points.push(`${x},${y1}`);
bottomPoints.unshift(`${x},${y0}`);
});
return `M ${points.join(' L ')} L ${bottomPoints.join(' L ')} Z`;
}
// 生成 X 轴刻度
function getXTicks(data: any[]): { x: number; label: string }[] {
if (data.length === 0) return [];
const step = Math.max(1, Math.floor(data.length / 6));
return data.filter((_, i) => i % step === 0 || i === data.length - 1).map(d => ({
x: xScale(d.date, data),
label: `${d.date.getMonth() + 1}/${d.date.getDate()}`
}));
}
// 生成 Y 轴刻度
function getYTicks(maxValue: number): { y: number; label: string }[] {
if (maxValue === 0) return [];
const ticks = [];
const step = Math.ceil(maxValue / 4 / 100) * 100;
for (let v = 0; v <= maxValue; v += step) {
ticks.push({
y: yScale(v, maxValue),
label: ${v}`
});
}
return ticks;
}
// Tooltip 状态
let tooltipData = $state<any>(null);
let tooltipX = $state(0);
let tooltipY = $state(0);
function handleMouseMove(event: MouseEvent, data: any[], maxValue: number) {
const svg = event.currentTarget as SVGSVGElement;
const rect = svg.getBoundingClientRect();
// 将像素坐标转换为 viewBox 坐标
const pixelX = event.clientX - rect.left;
const scaleRatio = chartWidth / rect.width;
const viewBoxX = pixelX * scaleRatio;
// 找到最近的数据点
let closestIdx = 0;
let closestDist = Infinity;
data.forEach((d, i) => {
const dx = Math.abs(xScale(d.date, data) - viewBoxX);
if (dx < closestDist) {
closestDist = dx;
closestIdx = i;
}
});
// 阈值也需要按比例调整
if (closestDist < 50 * scaleRatio) {
tooltipData = data[closestIdx];
tooltipX = xScale(data[closestIdx].date, data);
tooltipY = event.clientY - rect.top;
} else {
tooltipData = null;
}
}
function handleMouseLeave() {
tooltipData = null;
}
// 点击打开 Dialog
function handleClick(event: MouseEvent, data: any[], maxValue: number) {
if (data.length === 0) return;
const svg = event.currentTarget as SVGSVGElement;
const rect = svg.getBoundingClientRect();
// 将像素坐标转换为 viewBox 坐标
const pixelX = event.clientX - rect.left;
const scaleRatio = chartWidth / rect.width;
const viewBoxX = pixelX * scaleRatio;
// 找到最近的数据点
let closestIdx = 0;
let closestDist = Infinity;
data.forEach((d, i) => {
const dx = Math.abs(xScale(d.date, data) - viewBoxX);
if (dx < closestDist) {
closestDist = dx;
closestIdx = i;
}
});
// 点击图表任意位置都触发,选择最近的日期
const clickedDate = data[closestIdx].date;
const dateStr = clickedDate.toISOString().split('T')[0];
// 找出当天的所有支出记录
selectedDate = clickedDate;
selectedDateRecords = records.filter(r => {
if (r.income_expense !== '支出') return false;
const recordDateStr = r.time.split(' ')[0];
return recordDateStr === dateStr;
});
// 重置排序和分页状态
sortField = 'amount';
sortOrder = 'desc';
currentPage = 1;
dialogOpen = true;
}
// 计算选中日期的统计
let selectedDateStats = $derived.by(() => {
if (!selectedDate || selectedDateRecords.length === 0) return null;
const categoryMap = new Map<string, { amount: number; count: number }>();
let total = 0;
selectedDateRecords.forEach(r => {
const cat = r.category || '其他';
const amount = parseFloat(r.amount) || 0;
total += amount;
if (!categoryMap.has(cat)) {
categoryMap.set(cat, { amount: 0, count: 0 });
}
const stat = categoryMap.get(cat)!;
stat.amount += amount;
stat.count += 1;
});
const categories = Array.from(categoryMap.entries())
.map(([category, stat]) => ({ category, ...stat }))
.sort((a, b) => b.amount - a.amount);
return { total, categories, count: selectedDateRecords.length };
});
</script>
{#if processedData().data.length > 1}
{@const { data, categories, maxValue } = processedData()}
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between pb-2">
<div class="space-y-1.5">
<Card.Title class="flex items-center gap-2">
<Activity class="h-5 w-5" />
每日支出趋势
</Card.Title>
<Card.Description>
{descriptionText()}
</Card.Description>
</div>
<Select.Root type="single" value={timeRange} onValueChange={handleTimeRangeChange}>
<Select.Trigger class="w-[140px] h-8 text-xs">
{timeRangeOptions.find(o => o.value === timeRange)?.label}
</Select.Trigger>
<Select.Content>
{#each timeRangeOptions as option}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Card.Header>
<Card.Content>
<!-- 分类图例 (点击可切换显示) -->
<div class="flex flex-wrap gap-4 mb-4">
{#each categories as category, i}
{@const isHidden = hiddenCategories.has(category)}
<button
class="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity outline-none"
onclick={() => toggleCategory(category)}
title={isHidden ? '点击显示' : '点击隐藏'}
>
<div
class="w-3 h-3 rounded-sm transition-opacity {isHidden ? 'opacity-30' : ''}"
style="background-color: {getColor(i)}"
></div>
<span
class="text-xs transition-colors {isHidden ? 'text-muted-foreground/40 line-through' : 'text-muted-foreground'}"
>{category}</span>
</button>
{/each}
</div>
<!-- 堆叠面积图 (自定义 SVG) -->
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
<svg
viewBox="0 0 {chartWidth} {chartHeight}"
class="w-full h-full cursor-pointer outline-none focus:outline-none"
role="application"
aria-label="每日支出趋势图表,点击可查看当日详情"
tabindex="-1"
onmousemove={(e) => handleMouseMove(e, data, maxValue)}
onmouseleave={handleMouseLeave}
onclick={(e) => handleClick(e, data, maxValue)}
>
<!-- Y 轴 -->
<line
x1={padding.left}
y1={padding.top}
x2={padding.left}
y2={padding.top + innerHeight}
stroke="currentColor"
stroke-opacity="0.2"
/>
<!-- Y 轴刻度 -->
{#each getYTicks(maxValue) as tick}
<line
x1={padding.left}
y1={tick.y}
x2={padding.left + innerWidth}
y2={tick.y}
stroke="currentColor"
stroke-opacity="0.1"
stroke-dasharray="4"
/>
<text
x={padding.left - 8}
y={tick.y + 4}
text-anchor="end"
class="fill-muted-foreground text-[10px]"
>
{tick.label}
</text>
{/each}
<!-- X 轴 -->
<line
x1={padding.left}
y1={padding.top + innerHeight}
x2={padding.left + innerWidth}
y2={padding.top + innerHeight}
stroke="currentColor"
stroke-opacity="0.2"
/>
<!-- X 轴刻度 -->
{#each getXTicks(data) as tick}
<text
x={tick.x}
y={padding.top + innerHeight + 20}
text-anchor="middle"
class="fill-muted-foreground text-[10px]"
>
{tick.label}
</text>
{/each}
<!-- 堆叠面积 (从后向前渲染) -->
{#each [...categories].reverse() as category, i}
{@const colorIdx = categories.length - 1 - i}
<path
d={generateAreaPath(category, data, maxValue)}
fill={getColor(colorIdx)}
fill-opacity="0.7"
class="transition-opacity hover:opacity-90"
/>
{/each}
<!-- Tooltip 辅助线 -->
{#if tooltipData}
<line
x1={tooltipX}
y1={padding.top}
x2={tooltipX}
y2={padding.top + innerHeight}
stroke="currentColor"
stroke-opacity="0.3"
stroke-dasharray="4"
/>
<!-- 数据点 -->
{#each categories as category, i}
{#if tooltipData[category] > 0}
<circle
cx={tooltipX}
cy={yScale(tooltipData[`${category}_y1`] || 0, maxValue)}
r="4"
fill={getColor(i)}
stroke="white"
stroke-width="2"
/>
{/if}
{/each}
{/if}
</svg>
<!-- Tooltip -->
{#if tooltipData}
{@const tooltipLeftPercent = (tooltipX / chartWidth) * 100}
{@const adjustedLeft = tooltipLeftPercent > 75 ? tooltipLeftPercent - 25 : tooltipLeftPercent + 2}
<div
class="absolute pointer-events-none z-10 border-border/50 bg-background rounded-lg border px-3 py-2 text-xs shadow-xl min-w-[160px]"
style="left: {adjustedLeft}%; top: 15%;"
>
<div class="font-medium text-foreground mb-2">
{tooltipData.date.toLocaleDateString('zh-CN')}
</div>
<div class="space-y-1">
{#each categories as category, i}
{#if tooltipData[category] > 0}
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full"
style="background-color: {getColor(i)}"
></div>
<span class="text-muted-foreground">{category}</span>
</div>
<span class="font-mono font-medium">¥{tooltipData[category].toFixed(2)}</span>
</div>
{/if}
{/each}
</div>
<div class="border-t border-border mt-2 pt-2 flex justify-between">
<span class="text-muted-foreground">合计</span>
<span class="font-mono font-bold">¥{tooltipData.total.toFixed(2)}</span>
</div>
</div>
{/if}
</div>
<!-- 趋势指标 -->
<div class="flex items-center justify-center gap-2 mt-4 text-sm">
{#if trendInfo()}
{@const info = trendInfo()}
{#if info!.change >= 0}
<TrendingUp class="h-4 w-4 text-red-500" />
<span class="text-red-500">+{Math.abs(info!.changePercent).toFixed(1)}%</span>
{:else}
<TrendingDown class="h-4 w-4 text-green-500" />
<span class="text-green-500">-{Math.abs(info!.changePercent).toFixed(1)}%</span>
{/if}
<span class="text-muted-foreground">较前一天</span>
{/if}
</div>
<!-- 提示文字 -->
<p class="text-center text-xs text-muted-foreground mt-2">
点击图表查看当日详情
</p>
</Card.Content>
</Card.Root>
{/if}
<!-- 当日详情 Dialog -->
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="w-fit min-w-[500px] max-w-[90vw] max-h-[80vh] overflow-hidden flex flex-col">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Calendar class="h-5 w-5" />
{#if selectedDate}
{selectedDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })}
{/if}
</Dialog.Title>
<Dialog.Description>
{#if selectedDateStats}
{@const stats = selectedDateStats}
{stats!.count} 笔支出,合计 ¥{stats!.total.toFixed(2)}
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="flex-1 overflow-auto py-4">
{#if selectedDateStats}
{@const stats = selectedDateStats}
<!-- 分类汇总 -->
<div class="mb-4 space-y-2">
<h4 class="text-sm font-medium text-muted-foreground">分类汇总</h4>
<div class="grid grid-cols-2 gap-2">
{#each stats!.categories as cat, i}
<div class="flex items-center justify-between p-2 rounded-lg bg-muted/50">
<div class="flex items-center gap-2">
<div
class="w-2.5 h-2.5 rounded-full"
style="background-color: {getColor(i)}"
></div>
<span class="text-sm">{cat.category}</span>
<span class="text-xs text-muted-foreground">({cat.count}笔)</span>
</div>
<span class="font-mono text-sm font-medium text-red-600 dark:text-red-400">
¥{cat.amount.toFixed(2)}
</span>
</div>
{/each}
</div>
</div>
<!-- 详细记录 -->
<div>
<h4 class="text-sm font-medium text-muted-foreground mb-2">详细记录(点击表头排序)</h4>
<div class="rounded-md border overflow-auto max-h-[300px]">
<!-- 表头 -->
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50 border-b text-xs font-medium text-muted-foreground sticky top-0">
<button
class="w-[60px] flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('time')}
>
时间
{#if sortField === 'time'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
<button
class="w-[80px] flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('category')}
>
分类
{#if sortField === 'category'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
<button
class="flex-1 min-w-[100px] flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('merchant')}
>
商家
{#if sortField === 'merchant'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
<button
class="w-[100px] flex items-center justify-end gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('amount')}
>
金额
{#if sortField === 'amount'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</div>
<!-- 记录列表 -->
{#each paginatedRecords as record}
<div class="flex items-center gap-2 px-3 py-2 border-b last:border-b-0 hover:bg-muted/30">
<div class="w-[60px] text-xs text-muted-foreground">
{record.time.split(' ')[1] || '--:--'}
</div>
<div class="w-[80px] text-sm">{record.category}</div>
<div class="flex-1 min-w-[100px] text-sm truncate" title="{record.merchant}">
{record.merchant}
</div>
<div class="w-[100px] text-right font-mono text-sm font-medium text-red-600 dark:text-red-400">
¥{record.amount}
</div>
</div>
{/each}
</div>
<!-- 分页控件 -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-3 pt-3 border-t">
<div class="text-xs text-muted-foreground">
显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedDateRecords.length)} 条,共 {sortedDateRecords.length}
</div>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="icon"
class="h-7 w-7"
disabled={currentPage === 1}
onclick={() => goToPage(currentPage - 1)}
>
<ChevronLeft class="h-3.5 w-3.5" />
</Button>
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
<Button
variant={page === currentPage ? 'default' : 'outline'}
size="icon"
class="h-7 w-7 text-xs"
onclick={() => goToPage(page)}
>
{page}
</Button>
{:else if page === currentPage - 2 || page === currentPage + 2}
<span class="px-1 text-muted-foreground text-xs">...</span>
{/if}
{/each}
<Button
variant="outline"
size="icon"
class="h-7 w-7"
disabled={currentPage === totalPages}
onclick={() => goToPage(currentPage + 1)}
>
<ChevronRight class="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/if}
</div>
{:else}
<p class="text-center text-muted-foreground py-8">暂无数据</p>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
import Activity from '@lucide/svelte/icons/activity';
interface Props {
onLoadDemo: () => void;
}
let { onLoadDemo }: Props = $props();
</script>
<Card.Root>
<Card.Content class="flex flex-col items-center justify-center py-16">
<BarChart3 class="h-16 w-16 text-muted-foreground mb-4" />
<p class="text-lg font-medium">输入文件名开始分析</p>
<p class="text-sm text-muted-foreground mb-4">上传账单后可在此进行数据分析</p>
<Button variant="outline" onclick={onLoadDemo}>
<Activity class="mr-2 h-4 w-4" />
查看示例数据
</Button>
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
import type { MonthlyStat } from '$lib/types/analysis';
import { getPercentage } from '$lib/services/analysis';
interface Props {
monthlyStats: MonthlyStat[];
}
let { monthlyStats }: Props = $props();
let maxValue = $derived(Math.max(...monthlyStats.map(s => Math.max(s.expense, s.income))));
</script>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<BarChart3 class="h-5 w-5" />
月度趋势
</Card.Title>
<Card.Description>收支变化趋势</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#each monthlyStats as stat}
<div class="space-y-2">
<div class="text-sm font-medium">{stat.month}</div>
<div class="space-y-1">
<div class="flex items-center gap-2">
<div class="h-5 rounded bg-red-500/20 overflow-hidden flex-1 relative">
<div
class="h-full bg-red-500 transition-all duration-500"
style="width: {getPercentage(stat.expense, maxValue)}%"
></div>
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-mono">
{stat.expense.toFixed(0)}
</span>
</div>
</div>
<div class="flex items-center gap-2">
<div class="h-5 rounded bg-green-500/20 overflow-hidden flex-1 relative">
<div
class="h-full bg-green-500 transition-all duration-500"
style="width: {getPercentage(stat.income, maxValue)}%"
></div>
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-mono">
{stat.income.toFixed(0)}
</span>
</div>
</div>
</div>
</div>
{/each}
<!-- 图例 -->
<div class="flex justify-center gap-6 pt-2">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span class="w-3 h-3 rounded bg-red-500"></span>
支出
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span class="w-3 h-3 rounded bg-green-500"></span>
收入
</div>
</div>
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import TrendingDown from '@lucide/svelte/icons/trending-down';
import TrendingUp from '@lucide/svelte/icons/trending-up';
import Wallet from '@lucide/svelte/icons/wallet';
import type { TotalStats } from '$lib/types/analysis';
import { countByType } from '$lib/services/analysis';
import type { BillRecord } from '$lib/api';
interface Props {
totalStats: TotalStats;
records: BillRecord[];
}
let { totalStats, records }: Props = $props();
let balance = $derived(totalStats.income - totalStats.expense);
let expenseCount = $derived(countByType(records, '支出'));
let incomeCount = $derived(countByType(records, '收入'));
</script>
<div class="grid gap-4 md:grid-cols-3">
<Card.Root class="border-red-200 dark:border-red-900">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">总支出</Card.Title>
<TrendingDown class="h-4 w-4 text-red-500" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold font-mono text-red-600 dark:text-red-400">
¥{totalStats.expense.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">{expenseCount} 笔支出</p>
</Card.Content>
</Card.Root>
<Card.Root class="border-green-200 dark:border-green-900">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">总收入</Card.Title>
<TrendingUp class="h-4 w-4 text-green-500" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold font-mono text-green-600 dark:text-green-400">
¥{totalStats.income.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">{incomeCount} 笔收入</p>
</Card.Content>
</Card.Root>
<Card.Root class={balance >= 0 ? 'border-blue-200 dark:border-blue-900' : 'border-orange-200 dark:border-orange-900'}>
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">结余</Card.Title>
<Wallet class="h-4 w-4 {balance >= 0 ? 'text-blue-500' : 'text-orange-500'}" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold font-mono {balance >= 0 ? 'text-blue-600 dark:text-blue-400' : 'text-orange-600 dark:text-orange-400'}">
¥{balance.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">
{balance >= 0 ? '本期盈余' : '本期亏空'}
</p>
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import Flame from '@lucide/svelte/icons/flame';
import type { BillRecord } from '$lib/api';
interface Props {
records: BillRecord[];
}
let { records }: Props = $props();
</script>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Flame class="h-5 w-5 text-orange-500" />
Top 10 单笔支出
</Card.Title>
<Card.Description>最大的单笔支出记录</Card.Description>
</Card.Header>
<Card.Content>
<div class="space-y-3">
{#each records as record, i}
<div class="flex items-center gap-4 p-3 rounded-lg bg-muted/50">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary font-bold text-sm">
{i + 1}
</div>
<div class="flex-1 min-w-0">
<p class="font-medium truncate">{record.merchant}</p>
<p class="text-sm text-muted-foreground truncate">
{record.description || record.category}
</p>
</div>
<div class="font-mono font-bold text-red-600 dark:text-red-400">
¥{record.amount}
</div>
</div>
{/each}
</div>
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,8 @@
export { default as OverviewCards } from './OverviewCards.svelte';
export { default as DailyTrendChart } from './DailyTrendChart.svelte';
export { default as CategoryRanking } from './CategoryRanking.svelte';
export { default as MonthlyTrend } from './MonthlyTrend.svelte';
export { default as TopExpenses } from './TopExpenses.svelte';
export { default as EmptyState } from './EmptyState.svelte';

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.FallbackProps = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.ImageProps = $props();
</script>
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn("aspect-square size-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
loadingStatus = $bindable("loading"),
class: className,
...restProps
}: AvatarPrimitive.RootProps = $props();
</script>
<AvatarPrimitive.Root
bind:ref
bind:loadingStatus
data-slot="avatar"
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,13 @@
import Root from "./avatar.svelte";
import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
};

View File

@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("leading-none font-semibold", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import ChartStyle from "./chart-style.svelte";
import { setChartContext, type ChartConfig } from "./chart-utils.js";
const uid = $props.id();
let {
ref = $bindable(null),
id = uid,
class: className,
children,
config,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
config: ChartConfig;
} = $props();
const chartId = `chart-${id || uid.replace(/:/g, "")}`;
setChartContext({
get config() {
return config;
},
});
</script>
<div
bind:this={ref}
data-chart={chartId}
data-slot="chart"
class={cn(
"flex aspect-video justify-center overflow-visible text-xs",
// Overrides
//
// Stroke around dots/marks when hovering
"[&_.lc-highlight-point]:stroke-transparent",
// override the default stroke color of lines
"[&_.lc-line]:stroke-border/50",
// by default, layerchart shows a line intersecting the point when hovering, this hides that
"[&_.lc-highlight-line]:stroke-0",
// by default, when you hover a point on a stacked series chart, it will drop the opacity
// of the other series, this overrides that
"[&_.lc-area-path]:opacity-100 [&_.lc-highlight-line]:opacity-100 [&_.lc-highlight-point]:opacity-100 [&_.lc-spline-path]:opacity-100 [&_.lc-text]:text-xs [&_.lc-text-svg]:overflow-visible",
// We don't want the little tick lines between the axis labels and the chart, so we remove
// the stroke. The alternative is to manually disable `tickMarks` on the x/y axis of every
// chart.
"[&_.lc-axis-tick]:stroke-0",
// We don't want to display the rule on the x/y axis, as there is already going to be
// a grid line there and rule ends up overlapping the marks because it is rendered after
// the marks
"[&_.lc-rule-x-line:not(.lc-grid-x-rule)]:stroke-0 [&_.lc-rule-y-line:not(.lc-grid-y-rule)]:stroke-0",
"[&_.lc-grid-x-radial-line]:stroke-border [&_.lc-grid-x-radial-circle]:stroke-border",
"[&_.lc-grid-y-radial-line]:stroke-border [&_.lc-grid-y-radial-circle]:stroke-border",
// Legend adjustments
"[&_.lc-legend-swatch-button]:items-center [&_.lc-legend-swatch-button]:gap-1.5",
"[&_.lc-legend-swatch-group]:items-center [&_.lc-legend-swatch-group]:gap-4",
"[&_.lc-legend-swatch]:size-2.5 [&_.lc-legend-swatch]:rounded-[2px]",
// Labels
"[&_.lc-labels-text:not([fill])]:fill-foreground [&_text]:stroke-transparent",
// Tick labels on th x/y axes
"[&_.lc-axis-tick-label]:fill-muted-foreground [&_.lc-axis-tick-label]:font-normal",
"[&_.lc-tooltip-rects-g]:fill-transparent",
"[&_.lc-layout-svg-g]:fill-transparent",
"[&_.lc-root-container]:w-full",
className
)}
{...restProps}
>
<ChartStyle id={chartId} {config} />
{@render children?.()}
</div>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import { THEMES, type ChartConfig } from "./chart-utils.js";
let { id, config }: { id: string; config: ChartConfig } = $props();
const colorConfig = $derived(
config ? Object.entries(config).filter(([, config]) => config.theme || config.color) : null
);
const themeContents = $derived.by(() => {
if (!colorConfig || !colorConfig.length) return;
const themeContents = [];
for (let [_theme, prefix] of Object.entries(THEMES)) {
let content = `${prefix} [data-chart=${id}] {\n`;
const color = colorConfig.map(([key, itemConfig]) => {
const theme = _theme as keyof typeof itemConfig.theme;
const color = itemConfig.theme?.[theme] || itemConfig.color;
return color ? `\t--color-${key}: ${color};` : null;
});
content += color.join("\n") + "\n}";
themeContents.push(content);
}
return themeContents.join("\n");
});
</script>
{#if themeContents}
{#key id}
<svelte:element this={"style"}>
{themeContents}
</svelte:element>
{/key}
{/if}

View File

@@ -0,0 +1,159 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { getPayloadConfigFromPayload, useChart, type TooltipPayload } from "./chart-utils.js";
import { getTooltipContext, Tooltip as TooltipPrimitive } from "layerchart";
import type { Snippet } from "svelte";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function defaultFormatter(value: any, _payload: TooltipPayload[]) {
return `${value}`;
}
let {
ref = $bindable(null),
class: className,
hideLabel = false,
indicator = "dot",
hideIndicator = false,
labelKey,
label,
labelFormatter = defaultFormatter,
labelClassName,
formatter,
nameKey,
color,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> & {
hideLabel?: boolean;
label?: string;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
hideIndicator?: boolean;
labelClassName?: string;
labelFormatter?: // eslint-disable-next-line @typescript-eslint/no-explicit-any
((value: any, payload: TooltipPayload[]) => string | number | Snippet) | null;
formatter?: Snippet<
[
{
value: unknown;
name: string;
item: TooltipPayload;
index: number;
payload: TooltipPayload[];
},
]
>;
} = $props();
const chart = useChart();
const tooltipCtx = getTooltipContext();
const formattedLabel = $derived.by(() => {
if (hideLabel || !tooltipCtx.payload?.length) return null;
const [item] = tooltipCtx.payload;
const key = labelKey ?? item?.label ?? item?.name ?? "value";
const itemConfig = getPayloadConfigFromPayload(chart.config, item, key);
const value =
!labelKey && typeof label === "string"
? (chart.config[label as keyof typeof chart.config]?.label ?? label)
: (itemConfig?.label ?? item.label);
if (value === undefined) return null;
if (!labelFormatter) return value;
return labelFormatter(value, tooltipCtx.payload);
});
const nestLabel = $derived(tooltipCtx.payload.length === 1 && indicator !== "dot");
</script>
{#snippet TooltipLabel()}
{#if formattedLabel}
<div class={cn("font-medium", labelClassName)}>
{#if typeof formattedLabel === "function"}
{@render formattedLabel()}
{:else}
{formattedLabel}
{/if}
</div>
{/if}
{/snippet}
<TooltipPrimitive.Root variant="none">
<div
class={cn(
"border-border/50 bg-background grid min-w-[9rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
{...restProps}
>
{#if !nestLabel}
{@render TooltipLabel()}
{/if}
<div class="grid gap-1.5">
{#each tooltipCtx.payload as item, i (item.key + i)}
{@const key = `${nameKey || item.key || item.name || "value"}`}
{@const itemConfig = getPayloadConfigFromPayload(chart.config, item, key)}
{@const indicatorColor = color || item.payload?.color || item.color}
<div
class={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5",
indicator === "dot" && "items-center"
)}
>
{#if formatter && item.value !== undefined && item.name}
{@render formatter({
value: item.value,
name: item.name,
item,
index: i,
payload: tooltipCtx.payload,
})}
{:else}
{#if itemConfig?.icon}
<itemConfig.icon />
{:else if !hideIndicator}
<div
style="--color-bg: {indicatorColor}; --color-border: {indicatorColor};"
class={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"size-2.5": indicator === "dot",
"h-full w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
></div>
{/if}
<div
class={cn(
"flex flex-1 shrink-0 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div class="grid gap-1.5">
{#if nestLabel}
{@render TooltipLabel()}
{/if}
<span class="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{#if item.value !== undefined}
<span class="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
</TooltipPrimitive.Root>

View File

@@ -0,0 +1,66 @@
import type { Tooltip } from "layerchart";
import { getContext, setContext, type Component, type ComponentProps, type Snippet } from "svelte";
export const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: string;
icon?: Component;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
export type ExtractSnippetParams<T> = T extends Snippet<[infer P]> ? P : never;
export type TooltipPayload = ExtractSnippetParams<
ComponentProps<typeof Tooltip.Root>["children"]
>["payload"][number];
// Helper to extract item config from a payload.
export function getPayloadConfigFromPayload(
config: ChartConfig,
payload: TooltipPayload,
key: string
) {
if (typeof payload !== "object" || payload === null) return undefined;
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (payload.key === key) {
configLabelKey = payload.key;
} else if (payload.name === key) {
configLabelKey = payload.name;
} else if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload !== undefined &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
type ChartContextValue = {
config: ChartConfig;
};
const chartContextKey = Symbol("chart-context");
export function setChartContext(value: ChartContextValue) {
return setContext(chartContextKey, value);
}
export function useChart() {
return getContext<ChartContextValue>(chartContextKey);
}

View File

@@ -0,0 +1,6 @@
import ChartContainer from "./chart-container.svelte";
import ChartTooltip from "./chart-tooltip.svelte";
export { getPayloadConfigFromPayload, type ChartConfig } from "./chart-utils.js";
export { ChartContainer, ChartTooltip, ChartContainer as Container, ChartTooltip as Tooltip };

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-lg leading-none font-semibold", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,34 @@
import Root from "./dialog.svelte";
import Portal from "./dialog-portal.svelte";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable([]),
...restProps
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
</script>
<DropdownMenuPrimitive.CheckboxGroup
bind:ref
bind:value
data-slot="dropdown-menu-checkbox-group"
{...restProps}
/>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span
class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center"
>
{#if indeterminate}
<MinusIcon class="size-4" />
{:else}
<CheckIcon class={cn("size-4", !checked && "text-transparent")} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import DropdownMenuPortal from "./dropdown-menu-portal.svelte";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
sideOffset = 4,
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props();
</script>
<DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md outline-none",
className
)}
{...restProps}
/>
</DropdownMenuPortal>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
</script>
<DropdownMenuPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CircleIcon from "@lucide/svelte/icons/circle";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span
class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center"
>
{#if checked}
<CircleIcon class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn("bg-border -mx-1 my-1 h-px", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn("text-muted-foreground ms-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ms-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.SubProps = $props();
</script>
<DropdownMenuPrimitive.Sub bind:open {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.RootProps = $props();
</script>
<DropdownMenuPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,54 @@
import Root from "./dropdown-menu.svelte";
import Sub from "./dropdown-menu-sub.svelte";
import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte";
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
import Content from "./dropdown-menu-content.svelte";
import Group from "./dropdown-menu-group.svelte";
import Item from "./dropdown-menu-item.svelte";
import Label from "./dropdown-menu-label.svelte";
import RadioGroup from "./dropdown-menu-radio-group.svelte";
import RadioItem from "./dropdown-menu-radio-item.svelte";
import Separator from "./dropdown-menu-separator.svelte";
import Shortcut from "./dropdown-menu-shortcut.svelte";
import Trigger from "./dropdown-menu-trigger.svelte";
import SubContent from "./dropdown-menu-sub-content.svelte";
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
import GroupHeading from "./dropdown-menu-group-heading.svelte";
import Portal from "./dropdown-menu-portal.svelte";
export {
CheckboxGroup,
CheckboxItem,
Content,
Portal,
Root as DropdownMenu,
CheckboxGroup as DropdownMenuCheckboxGroup,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Portal as DropdownMenuPortal,
Group as DropdownMenuGroup,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger,
};

View File

@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,37 @@
import Root from "./select.svelte";
import Group from "./select-group.svelte";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
import Portal from "./select-portal.svelte";
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Portal as SelectPortal,
};

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectPortal from "./select-portal.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
/>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />

View File

@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,34 @@
import Root from "./sheet.svelte";
import Portal from "./sheet-portal.svelte";
import Trigger from "./sheet-trigger.svelte";
import Close from "./sheet-close.svelte";
import Overlay from "./sheet-overlay.svelte";
import Content from "./sheet-content.svelte";
import Header from "./sheet-header.svelte";
import Footer from "./sheet-footer.svelte";
import Title from "./sheet-title.svelte";
import Description from "./sheet-description.svelte";
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
</script>
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />

View File

@@ -0,0 +1,60 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const sheetVariants = tv({
base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
variants: {
side: {
top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
left: "data-[state=closed]:slide-out-to-start data-[state=open]:slide-in-from-start inset-y-0 start-0 h-full w-3/4 border-e sm:max-w-sm",
right: "data-[state=closed]:slide-out-to-end data-[state=open]:slide-in-from-end inset-y-0 end-0 h-full w-3/4 border-s sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
});
export type Side = VariantProps<typeof sheetVariants>["side"];
</script>
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import SheetPortal from "./sheet-portal.svelte";
import SheetOverlay from "./sheet-overlay.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
side = "right",
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SheetPortal>>;
side?: Side;
children: Snippet;
} = $props();
</script>
<SheetPortal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content
bind:ref
data-slot="sheet-content"
class={cn(sheetVariants({ side }), className)}
{...restProps}
>
{@render children?.()}
<SheetPrimitive.Close
class="ring-offset-background focus-visible:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
>
<XIcon class="size-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script>
<SheetPrimitive.Description
bind:ref
data-slot="sheet-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-footer"
class={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-header"
class={cn("flex flex-col gap-1.5 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
</script>
<SheetPrimitive.Overlay
bind:ref
data-slot="sheet-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ...restProps }: SheetPrimitive.PortalProps = $props();
</script>
<SheetPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.TitleProps = $props();
</script>
<SheetPrimitive.Title
bind:ref
data-slot="sheet-title"
class={cn("text-foreground font-semibold", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
</script>
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: SheetPrimitive.RootProps = $props();
</script>
<SheetPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,6 @@
export const SIDEBAR_COOKIE_NAME = "sidebar:state";
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = "16rem";
export const SIDEBAR_WIDTH_MOBILE = "18rem";
export const SIDEBAR_WIDTH_ICON = "3rem";
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";

View File

@@ -0,0 +1,81 @@
import { IsMobile } from "$lib/hooks/is-mobile.svelte.js";
import { getContext, setContext } from "svelte";
import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js";
type Getter<T> = () => T;
export type SidebarStateProps = {
/**
* A getter function that returns the current open state of the sidebar.
* We use a getter function here to support `bind:open` on the `Sidebar.Provider`
* component.
*/
open: Getter<boolean>;
/**
* A function that sets the open state of the sidebar. To support `bind:open`, we need
* a source of truth for changing the open state to ensure it will be synced throughout
* the sub-components and any `bind:` references.
*/
setOpen: (open: boolean) => void;
};
class SidebarState {
readonly props: SidebarStateProps;
open = $derived.by(() => this.props.open());
openMobile = $state(false);
setOpen: SidebarStateProps["setOpen"];
#isMobile: IsMobile;
state = $derived.by(() => (this.open ? "expanded" : "collapsed"));
constructor(props: SidebarStateProps) {
this.setOpen = props.setOpen;
this.#isMobile = new IsMobile();
this.props = props;
}
// Convenience getter for checking if the sidebar is mobile
// without this, we would need to use `sidebar.isMobile.current` everywhere
get isMobile() {
return this.#isMobile.current;
}
// Event handler to apply to the `<svelte:window>`
handleShortcutKeydown = (e: KeyboardEvent) => {
if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
this.toggle();
}
};
setOpenMobile = (value: boolean) => {
this.openMobile = value;
};
toggle = () => {
return this.#isMobile.current
? (this.openMobile = !this.openMobile)
: this.setOpen(!this.open);
};
}
const SYMBOL_KEY = "scn-sidebar";
/**
* Instantiates a new `SidebarState` instance and sets it in the context.
*
* @param props The constructor props for the `SidebarState` class.
* @returns The `SidebarState` instance.
*/
export function setSidebar(props: SidebarStateProps): SidebarState {
return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
}
/**
* Retrieves the `SidebarState` instance from the context. This is a class instance,
* so you cannot destructure it.
* @returns The `SidebarState` instance.
*/
export function useSidebar(): SidebarState {
return getContext(Symbol.for(SYMBOL_KEY));
}

View File

@@ -0,0 +1,75 @@
import { useSidebar } from "./context.svelte.js";
import Content from "./sidebar-content.svelte";
import Footer from "./sidebar-footer.svelte";
import GroupAction from "./sidebar-group-action.svelte";
import GroupContent from "./sidebar-group-content.svelte";
import GroupLabel from "./sidebar-group-label.svelte";
import Group from "./sidebar-group.svelte";
import Header from "./sidebar-header.svelte";
import Input from "./sidebar-input.svelte";
import Inset from "./sidebar-inset.svelte";
import MenuAction from "./sidebar-menu-action.svelte";
import MenuBadge from "./sidebar-menu-badge.svelte";
import MenuButton from "./sidebar-menu-button.svelte";
import MenuItem from "./sidebar-menu-item.svelte";
import MenuSkeleton from "./sidebar-menu-skeleton.svelte";
import MenuSubButton from "./sidebar-menu-sub-button.svelte";
import MenuSubItem from "./sidebar-menu-sub-item.svelte";
import MenuSub from "./sidebar-menu-sub.svelte";
import Menu from "./sidebar-menu.svelte";
import Provider from "./sidebar-provider.svelte";
import Rail from "./sidebar-rail.svelte";
import Separator from "./sidebar-separator.svelte";
import Trigger from "./sidebar-trigger.svelte";
import Root from "./sidebar.svelte";
export {
Content,
Footer,
Group,
GroupAction,
GroupContent,
GroupLabel,
Header,
Input,
Inset,
Menu,
MenuAction,
MenuBadge,
MenuButton,
MenuItem,
MenuSkeleton,
MenuSub,
MenuSubButton,
MenuSubItem,
Provider,
Rail,
Root,
Separator,
//
Root as Sidebar,
Content as SidebarContent,
Footer as SidebarFooter,
Group as SidebarGroup,
GroupAction as SidebarGroupAction,
GroupContent as SidebarGroupContent,
GroupLabel as SidebarGroupLabel,
Header as SidebarHeader,
Input as SidebarInput,
Inset as SidebarInset,
Menu as SidebarMenu,
MenuAction as SidebarMenuAction,
MenuBadge as SidebarMenuBadge,
MenuButton as SidebarMenuButton,
MenuItem as SidebarMenuItem,
MenuSkeleton as SidebarMenuSkeleton,
MenuSub as SidebarMenuSub,
MenuSubButton as SidebarMenuSubButton,
MenuSubItem as SidebarMenuSubItem,
Provider as SidebarProvider,
Rail as SidebarRail,
Separator as SidebarSeparator,
Trigger as SidebarTrigger,
Trigger,
useSidebar,
};

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-content"
data-sidebar="content"
class={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-footer"
data-sidebar="footer"
class={cn("flex flex-col gap-2 p-2", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
child,
...restProps
}: WithElementRef<HTMLButtonAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute end-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
),
"data-slot": "sidebar-group-action",
"data-sidebar": "group-action",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-group-content"
data-sidebar="group-content"
class={cn("w-full text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
child,
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
),
"data-slot": "sidebar-group-label",
"data-sidebar": "group-label",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render children?.()}
</div>
{/if}

Some files were not shown because too many files have changed in this diff Show More