feat: 完善项目架构并增强分析页面功能
- 新增项目文档和 Docker 配置 - 添加 README.md 和 TODO.md 项目文档 - 为各服务添加 Dockerfile 和 docker-compose 配置 - 重构后端架构 - 新增 adapter 层(HTTP/Python 适配器) - 新增 repository 层(数据访问抽象) - 新增 router 模块统一管理路由 - 新增账单处理 handler - 扩展前端 UI 组件库 - 新增 Calendar、DateRangePicker、Drawer、Popover 等组件 - 集成 shadcn-svelte 组件库 - 增强分析页面功能 - 添加时间范围筛选器(支持本月默认值) - 修复 DateRangePicker 默认值显示问题 - 优化数据获取和展示逻辑 - 完善分析器服务 - 新增 FastAPI 服务接口 - 改进账单清理器实现
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Drawer from '$lib/components/ui/drawer';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
@@ -334,22 +334,22 @@
|
||||
{/if}
|
||||
|
||||
<!-- 详情/编辑弹窗 -->
|
||||
<Dialog.Root bind:open={detailDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Drawer.Root bind:open={detailDialogOpen}>
|
||||
<Drawer.Content class="sm:max-w-md">
|
||||
<Drawer.Header>
|
||||
<Drawer.Title class="flex items-center gap-2">
|
||||
<Receipt class="h-5 w-5" />
|
||||
{isEditing ? '编辑账单' : '账单详情'}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
</Drawer.Title>
|
||||
<Drawer.Description>
|
||||
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的详细信息'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
|
||||
{#if selectedRecord}
|
||||
{#if isEditing}
|
||||
<!-- 编辑表单 -->
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="space-y-4 py-4 px-4 md:px-0">
|
||||
<div class="space-y-2">
|
||||
<Label>金额</Label>
|
||||
<div class="relative">
|
||||
@@ -400,7 +400,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 详情展示 -->
|
||||
<div class="py-4">
|
||||
<div class="py-4 px-4 md:px-0">
|
||||
<div class="text-center mb-6">
|
||||
<div class="text-3xl font-bold text-red-600 dark:text-red-400 font-mono">
|
||||
¥{selectedRecord.amount}
|
||||
@@ -459,7 +459,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer>
|
||||
<Drawer.Footer>
|
||||
{#if isEditing}
|
||||
<Button variant="outline" onclick={cancelEdit}>
|
||||
<X class="h-4 w-4 mr-2" />
|
||||
@@ -478,6 +478,6 @@
|
||||
编辑
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Drawer.Footer>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Drawer from '$lib/components/ui/drawer';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import PieChartIcon from '@lucide/svelte/icons/pie-chart';
|
||||
import ListIcon from '@lucide/svelte/icons/list';
|
||||
@@ -147,6 +147,7 @@
|
||||
{@const x4 = Math.cos(startAngle) * innerRadius}
|
||||
{@const y4 = Math.sin(startAngle) * innerRadius}
|
||||
{@const largeArc = (endAngle - startAngle) > Math.PI ? 1 : 0}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<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}
|
||||
@@ -197,28 +198,28 @@
|
||||
</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">
|
||||
<Drawer.Root bind:open={dialogOpen}>
|
||||
<Drawer.Content class="sm:max-w-4xl">
|
||||
<Drawer.Header>
|
||||
<Drawer.Title class="flex items-center gap-2">
|
||||
<PieChartIcon class="h-5 w-5" />
|
||||
{selectedCategory} - 账单明细
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
</Drawer.Title>
|
||||
<Drawer.Description>
|
||||
{#if selectedStat}
|
||||
共 {selectedStat.count} 笔,合计 ¥{selectedStat.expense.toFixed(2)}
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
|
||||
<div class="flex-1 overflow-auto mt-4">
|
||||
<div class="flex-1 overflow-auto px-4 md:px-0">
|
||||
<BillRecordsTable records={selectedRecords} showDescription={true} {categories} />
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Drawer.Footer>
|
||||
<Button variant="outline" onclick={() => dialogOpen = false}>
|
||||
关闭
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Drawer.Footer>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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 * as Drawer from '$lib/components/ui/drawer';
|
||||
import Activity from '@lucide/svelte/icons/activity';
|
||||
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
||||
import TrendingDown from '@lucide/svelte/icons/trending-down';
|
||||
@@ -46,6 +46,12 @@
|
||||
hiddenCategories = newSet;
|
||||
}
|
||||
|
||||
// 提取日期字符串 (YYYY-MM-DD) - 兼容多种格式
|
||||
function extractDateStr(timeStr: string): string {
|
||||
// 处理 ISO 格式 (2025-12-29T10:30:00Z) 或空格格式 (2025-12-29 10:30:00)
|
||||
return timeStr.split('T')[0].split(' ')[0];
|
||||
}
|
||||
|
||||
const timeRangeOptions = [
|
||||
{ value: '7d', label: '最近 7 天' },
|
||||
{ value: 'week', label: '本周' },
|
||||
@@ -119,7 +125,7 @@
|
||||
// 过滤支出记录
|
||||
const expenseRecords = records.filter(r => {
|
||||
if (r.income_expense !== '支出') return false;
|
||||
const recordDate = new Date(r.time.split(' ')[0]);
|
||||
const recordDate = new Date(extractDateStr(r.time));
|
||||
return recordDate >= cutoffDate;
|
||||
});
|
||||
|
||||
@@ -130,7 +136,7 @@
|
||||
const categoryTotals: Record<string, number> = {};
|
||||
|
||||
expenseRecords.forEach(record => {
|
||||
const dateStr = record.time.split(' ')[0];
|
||||
const dateStr = extractDateStr(record.time);
|
||||
const category = record.category || '其他';
|
||||
const amount = parseFloat(record.amount) || 0;
|
||||
|
||||
@@ -526,7 +532,7 @@
|
||||
selectedDate = clickedDate;
|
||||
selectedDateRecords = records.filter(r => {
|
||||
if (r.income_expense !== '支出') return false;
|
||||
const recordDateStr = r.time.split(' ')[0];
|
||||
const recordDateStr = extractDateStr(r.time);
|
||||
return recordDateStr === dateStr;
|
||||
});
|
||||
|
||||
@@ -641,7 +647,7 @@
|
||||
|
||||
<!-- 趋势图 (自定义 SVG) -->
|
||||
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<svg
|
||||
viewBox="0 0 {chartWidth} {chartHeight}"
|
||||
class="w-full h-full cursor-pointer outline-none focus:outline-none"
|
||||
@@ -827,25 +833,25 @@
|
||||
</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">
|
||||
<!-- 当日详情 Drawer -->
|
||||
<Drawer.Root bind:open={dialogOpen}>
|
||||
<Drawer.Content class="sm:max-w-4xl">
|
||||
<Drawer.Header>
|
||||
<Drawer.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>
|
||||
</Drawer.Title>
|
||||
<Drawer.Description>
|
||||
{#if selectedDateStats}
|
||||
{@const stats = selectedDateStats}
|
||||
共 {stats!.count} 笔支出,合计 ¥{stats!.total.toFixed(2)}
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
|
||||
<div class="flex-1 overflow-auto py-4">
|
||||
<div class="flex-1 overflow-auto py-4 px-4 md:px-0">
|
||||
{#if selectedDateStats}
|
||||
{@const stats = selectedDateStats}
|
||||
|
||||
@@ -886,5 +892,5 @@
|
||||
<p class="text-center text-muted-foreground py-8">暂无数据</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Drawer from '$lib/components/ui/drawer';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
@@ -134,10 +134,10 @@
|
||||
</Card.Root>
|
||||
|
||||
<!-- 账单详情弹窗 -->
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-[450px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Drawer.Root bind:open={dialogOpen}>
|
||||
<Drawer.Content class="sm:max-w-[450px]">
|
||||
<Drawer.Header>
|
||||
<Drawer.Title class="flex items-center gap-2">
|
||||
<Receipt class="h-5 w-5" />
|
||||
{isEditing ? '编辑账单' : '账单详情'}
|
||||
{#if selectedRank <= 3 && !isEditing}
|
||||
@@ -149,16 +149,16 @@
|
||||
Top {selectedRank}
|
||||
</span>
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
</Drawer.Title>
|
||||
<Drawer.Description>
|
||||
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的完整信息'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
|
||||
{#if selectedRecord}
|
||||
{#if isEditing}
|
||||
<!-- 编辑模式 -->
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="py-4 space-y-4 px-4 md:px-0">
|
||||
<div class="space-y-2">
|
||||
<Label for="amount">金额</Label>
|
||||
<div class="relative">
|
||||
@@ -206,7 +206,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 查看模式 -->
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="py-4 space-y-4 px-4 md:px-0">
|
||||
<!-- 金额 -->
|
||||
<div class="text-center py-4 bg-red-50 dark:bg-red-950/30 rounded-lg">
|
||||
<p class="text-sm text-muted-foreground mb-1">支出金额</p>
|
||||
@@ -265,7 +265,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer class="flex gap-2">
|
||||
<Drawer.Footer class="flex gap-2">
|
||||
{#if isEditing}
|
||||
<Button variant="outline" onclick={cancelEdit}>
|
||||
<X class="h-4 w-4 mr-2" />
|
||||
@@ -284,6 +284,6 @@
|
||||
编辑
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Drawer.Footer>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
|
||||
76
web/src/lib/components/ui/calendar/calendar-caption.svelte
Normal file
76
web/src/lib/components/ui/calendar/calendar-caption.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from "svelte";
|
||||
import type Calendar from "./calendar.svelte";
|
||||
import CalendarMonthSelect from "./calendar-month-select.svelte";
|
||||
import CalendarYearSelect from "./calendar-year-select.svelte";
|
||||
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
|
||||
|
||||
let {
|
||||
captionLayout,
|
||||
months,
|
||||
monthFormat,
|
||||
years,
|
||||
yearFormat,
|
||||
month,
|
||||
locale,
|
||||
placeholder = $bindable(),
|
||||
monthIndex = 0,
|
||||
}: {
|
||||
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
|
||||
months: ComponentProps<typeof CalendarMonthSelect>["months"];
|
||||
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
|
||||
years: ComponentProps<typeof CalendarYearSelect>["years"];
|
||||
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
|
||||
month: DateValue;
|
||||
placeholder: DateValue | undefined;
|
||||
locale: string;
|
||||
monthIndex: number;
|
||||
} = $props();
|
||||
|
||||
function formatYear(date: DateValue) {
|
||||
const dateObj = date.toDate(getLocalTimeZone());
|
||||
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
|
||||
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
||||
}
|
||||
|
||||
function formatMonth(date: DateValue) {
|
||||
const dateObj = date.toDate(getLocalTimeZone());
|
||||
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
|
||||
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet MonthSelect()}
|
||||
<CalendarMonthSelect
|
||||
{months}
|
||||
{monthFormat}
|
||||
value={month.month}
|
||||
onchange={(e) => {
|
||||
if (!placeholder) return;
|
||||
const v = Number.parseInt(e.currentTarget.value);
|
||||
const newPlaceholder = placeholder.set({ month: v });
|
||||
placeholder = newPlaceholder.subtract({ months: monthIndex });
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet YearSelect()}
|
||||
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
||||
{/snippet}
|
||||
|
||||
{#if captionLayout === "dropdown"}
|
||||
{@render MonthSelect()}
|
||||
{@render YearSelect()}
|
||||
{:else if captionLayout === "dropdown-months"}
|
||||
{@render MonthSelect()}
|
||||
{#if placeholder}
|
||||
{formatYear(placeholder)}
|
||||
{/if}
|
||||
{:else if captionLayout === "dropdown-years"}
|
||||
{#if placeholder}
|
||||
{formatMonth(placeholder)}
|
||||
{/if}
|
||||
{@render YearSelect()}
|
||||
{:else}
|
||||
{formatMonth(month)} {formatYear(month)}
|
||||
{/if}
|
||||
19
web/src/lib/components/ui/calendar/calendar-cell.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-cell.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.CellProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Cell
|
||||
bind:ref
|
||||
class={cn(
|
||||
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
35
web/src/lib/components/ui/calendar/calendar-day.svelte
Normal file
35
web/src/lib/components/ui/calendar/calendar-day.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.DayProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Day
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none",
|
||||
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
|
||||
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
|
||||
// Outside months
|
||||
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
||||
// Disabled
|
||||
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
// Unavailable
|
||||
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
|
||||
// hover
|
||||
"dark:hover:text-accent-foreground",
|
||||
// focus
|
||||
"focus:border-ring focus:ring-ring/50 focus:relative",
|
||||
// inner spans
|
||||
"[&>span]:text-xs [&>span]:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
12
web/src/lib/components/ui/calendar/calendar-grid-body.svelte
Normal file
12
web/src/lib/components/ui/calendar/calendar-grid-body.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridBodyProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
|
||||
12
web/src/lib/components/ui/calendar/calendar-grid-head.svelte
Normal file
12
web/src/lib/components/ui/calendar/calendar-grid-head.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridHeadProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
|
||||
12
web/src/lib/components/ui/calendar/calendar-grid-row.svelte
Normal file
12
web/src/lib/components/ui/calendar/calendar-grid-row.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridRowProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
|
||||
16
web/src/lib/components/ui/calendar/calendar-grid.svelte
Normal file
16
web/src/lib/components/ui/calendar/calendar-grid.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Grid
|
||||
bind:ref
|
||||
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
19
web/src/lib/components/ui/calendar/calendar-head-cell.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-head-cell.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeadCellProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.HeadCell
|
||||
bind:ref
|
||||
class={cn(
|
||||
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
19
web/src/lib/components/ui/calendar/calendar-header.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-header.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeaderProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Header
|
||||
bind:ref
|
||||
class={cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
16
web/src/lib/components/ui/calendar/calendar-heading.svelte
Normal file
16
web/src/lib/components/ui/calendar/calendar-heading.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeadingProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Heading
|
||||
bind:ref
|
||||
class={cn("px-(--cell-size) text-sm font-medium", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
onchange,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||
{#snippet child({ props, monthItems, selectedMonthItem })}
|
||||
<select {...props} {value} {onchange}>
|
||||
{#each monthItems as monthItem (monthItem.value)}
|
||||
<option
|
||||
value={monthItem.value}
|
||||
selected={value !== undefined
|
||||
? monthItem.value === value
|
||||
: monthItem.value === selectedMonthItem.value}
|
||||
>
|
||||
{monthItem.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span
|
||||
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.MonthSelect>
|
||||
</span>
|
||||
15
web/src/lib/components/ui/calendar/calendar-month.svelte
Normal file
15
web/src/lib/components/ui/calendar/calendar-month.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { type WithElementRef, cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
19
web/src/lib/components/ui/calendar/calendar-months.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-months.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<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}
|
||||
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
19
web/src/lib/components/ui/calendar/calendar-nav.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-nav.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<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<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<nav
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</nav>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
variant = "ghost",
|
||||
...restProps
|
||||
}: CalendarPrimitive.NextButtonProps & {
|
||||
variant?: ButtonVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronRightIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<CalendarPrimitive.NextButton
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant }),
|
||||
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
variant = "ghost",
|
||||
...restProps
|
||||
}: CalendarPrimitive.PrevButtonProps & {
|
||||
variant?: ButtonVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronLeftIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<CalendarPrimitive.PrevButton
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant }),
|
||||
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarPrimitive.YearSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||
{#snippet child({ props, yearItems, selectedYearItem })}
|
||||
<select {...props} {value}>
|
||||
{#each yearItems as yearItem (yearItem.value)}
|
||||
<option
|
||||
value={yearItem.value}
|
||||
selected={value !== undefined
|
||||
? yearItem.value === value
|
||||
: yearItem.value === selectedYearItem.value}
|
||||
>
|
||||
{yearItem.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span
|
||||
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.YearSelect>
|
||||
</span>
|
||||
115
web/src/lib/components/ui/calendar/calendar.svelte
Normal file
115
web/src/lib/components/ui/calendar/calendar.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import * as Calendar from "./index.js";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { ButtonVariant } from "../button/button.svelte";
|
||||
import { isEqualMonth, type DateValue } from "@internationalized/date";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
placeholder = $bindable(),
|
||||
class: className,
|
||||
weekdayFormat = "short",
|
||||
buttonVariant = "ghost",
|
||||
captionLayout = "label",
|
||||
locale = "en-US",
|
||||
months: monthsProp,
|
||||
years,
|
||||
monthFormat: monthFormatProp,
|
||||
yearFormat = "numeric",
|
||||
day,
|
||||
disableDaysOutsideMonth = false,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
|
||||
buttonVariant?: ButtonVariant;
|
||||
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
|
||||
months?: CalendarPrimitive.MonthSelectProps["months"];
|
||||
years?: CalendarPrimitive.YearSelectProps["years"];
|
||||
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
|
||||
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
|
||||
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
||||
} = $props();
|
||||
|
||||
const monthFormat = $derived.by(() => {
|
||||
if (monthFormatProp) return monthFormatProp;
|
||||
if (captionLayout.startsWith("dropdown")) return "short";
|
||||
return "long";
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Discriminated Unions + Destructing (required for bindable) do not
|
||||
get along, so we shut typescript up by casting `value` to `never`.
|
||||
-->
|
||||
<CalendarPrimitive.Root
|
||||
bind:value={value as never}
|
||||
bind:ref
|
||||
bind:placeholder
|
||||
{weekdayFormat}
|
||||
{disableDaysOutsideMonth}
|
||||
class={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{locale}
|
||||
{monthFormat}
|
||||
{yearFormat}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ months, weekdays })}
|
||||
<Calendar.Months>
|
||||
<Calendar.Nav>
|
||||
<Calendar.PrevButton variant={buttonVariant} />
|
||||
<Calendar.NextButton variant={buttonVariant} />
|
||||
</Calendar.Nav>
|
||||
{#each months as month, monthIndex (month)}
|
||||
<Calendar.Month>
|
||||
<Calendar.Header>
|
||||
<Calendar.Caption
|
||||
{captionLayout}
|
||||
months={monthsProp}
|
||||
{monthFormat}
|
||||
{years}
|
||||
{yearFormat}
|
||||
month={month.value}
|
||||
bind:placeholder
|
||||
{locale}
|
||||
{monthIndex}
|
||||
/>
|
||||
</Calendar.Header>
|
||||
<Calendar.Grid>
|
||||
<Calendar.GridHead>
|
||||
<Calendar.GridRow class="select-none">
|
||||
{#each weekdays as weekday (weekday)}
|
||||
<Calendar.HeadCell>
|
||||
{weekday.slice(0, 2)}
|
||||
</Calendar.HeadCell>
|
||||
{/each}
|
||||
</Calendar.GridRow>
|
||||
</Calendar.GridHead>
|
||||
<Calendar.GridBody>
|
||||
{#each month.weeks as weekDates (weekDates)}
|
||||
<Calendar.GridRow class="mt-2 w-full">
|
||||
{#each weekDates as date (date)}
|
||||
<Calendar.Cell {date} month={month.value}>
|
||||
{#if day}
|
||||
{@render day({
|
||||
day: date,
|
||||
outsideMonth: !isEqualMonth(date, month.value),
|
||||
})}
|
||||
{:else}
|
||||
<Calendar.Day />
|
||||
{/if}
|
||||
</Calendar.Cell>
|
||||
{/each}
|
||||
</Calendar.GridRow>
|
||||
{/each}
|
||||
</Calendar.GridBody>
|
||||
</Calendar.Grid>
|
||||
</Calendar.Month>
|
||||
{/each}
|
||||
</Calendar.Months>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.Root>
|
||||
40
web/src/lib/components/ui/calendar/index.ts
Normal file
40
web/src/lib/components/ui/calendar/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import Root from "./calendar.svelte";
|
||||
import Cell from "./calendar-cell.svelte";
|
||||
import Day from "./calendar-day.svelte";
|
||||
import Grid from "./calendar-grid.svelte";
|
||||
import Header from "./calendar-header.svelte";
|
||||
import Months from "./calendar-months.svelte";
|
||||
import GridRow from "./calendar-grid-row.svelte";
|
||||
import Heading from "./calendar-heading.svelte";
|
||||
import GridBody from "./calendar-grid-body.svelte";
|
||||
import GridHead from "./calendar-grid-head.svelte";
|
||||
import HeadCell from "./calendar-head-cell.svelte";
|
||||
import NextButton from "./calendar-next-button.svelte";
|
||||
import PrevButton from "./calendar-prev-button.svelte";
|
||||
import MonthSelect from "./calendar-month-select.svelte";
|
||||
import YearSelect from "./calendar-year-select.svelte";
|
||||
import Month from "./calendar-month.svelte";
|
||||
import Nav from "./calendar-nav.svelte";
|
||||
import Caption from "./calendar-caption.svelte";
|
||||
|
||||
export {
|
||||
Day,
|
||||
Cell,
|
||||
Grid,
|
||||
Header,
|
||||
Months,
|
||||
GridRow,
|
||||
Heading,
|
||||
GridBody,
|
||||
GridHead,
|
||||
HeadCell,
|
||||
NextButton,
|
||||
PrevButton,
|
||||
Nav,
|
||||
Month,
|
||||
YearSelect,
|
||||
MonthSelect,
|
||||
Caption,
|
||||
//
|
||||
Root as Calendar,
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { CalendarDate, type DateValue } from "@internationalized/date";
|
||||
import CalendarIcon from "@lucide/svelte/icons/calendar";
|
||||
import * as Popover from "$lib/components/ui/popover";
|
||||
import { RangeCalendar } from "$lib/components/ui/range-calendar";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { cn } from "$lib/utils";
|
||||
import type { DateRange } from "bits-ui";
|
||||
|
||||
interface Props {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
onchange?: (start: string, end: string) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { startDate = $bindable(), endDate = $bindable(), onchange, class: className }: Props = $props();
|
||||
|
||||
// 将 YYYY-MM-DD 字符串转换为 CalendarDate
|
||||
function parseDate(dateStr: string): DateValue | undefined {
|
||||
if (!dateStr) return undefined;
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
return new CalendarDate(year, month, day);
|
||||
}
|
||||
|
||||
// 将 CalendarDate 转换为 YYYY-MM-DD 字符串
|
||||
function formatDate(date: DateValue | undefined): string {
|
||||
if (!date) return '';
|
||||
return `${date.year}-${String(date.month).padStart(2, '0')}-${String(date.day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 内部日期范围状态,使用 $derived 响应 props 变化
|
||||
let value: DateRange = $derived({
|
||||
start: parseDate(startDate),
|
||||
end: parseDate(endDate)
|
||||
});
|
||||
|
||||
// 格式化显示文本
|
||||
let displayText = $derived(() => {
|
||||
if (value.start && value.end) {
|
||||
return `${formatDate(value.start)} ~ ${formatDate(value.end)}`;
|
||||
}
|
||||
if (value.start) {
|
||||
return `${formatDate(value.start)} ~ `;
|
||||
}
|
||||
return "选择日期范围";
|
||||
});
|
||||
|
||||
// 当日期变化时通知父组件
|
||||
function handleValueChange(newValue: DateRange) {
|
||||
if (newValue.start && newValue.end && onchange) {
|
||||
onchange(formatDate(newValue.start), formatDate(newValue.end));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
variant="outline"
|
||||
class={cn(
|
||||
"w-[260px] justify-start text-left font-normal",
|
||||
!value.start && "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
{displayText()}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-auto p-0" align="start">
|
||||
<RangeCalendar
|
||||
{value}
|
||||
onValueChange={handleValueChange}
|
||||
numberOfMonths={2}
|
||||
locale="zh-CN"
|
||||
weekStartsOn={1}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
3
web/src/lib/components/ui/date-range-picker/index.ts
Normal file
3
web/src/lib/components/ui/date-range-picker/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import DateRangePicker from "./date-range-picker.svelte";
|
||||
|
||||
export { DateRangePicker };
|
||||
25
web/src/lib/components/ui/drawer/drawer-close.svelte
Normal file
25
web/src/lib/components/ui/drawer/drawer-close.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { children, class: className }: Props = $props();
|
||||
|
||||
const isMobile = new IsMobile();
|
||||
</script>
|
||||
|
||||
{#if isMobile.current}
|
||||
<Sheet.Close class={className}>
|
||||
{@render children?.()}
|
||||
</Sheet.Close>
|
||||
{:else}
|
||||
<Dialog.Close class={className}>
|
||||
{@render children?.()}
|
||||
</Dialog.Close>
|
||||
{/if}
|
||||
33
web/src/lib/components/ui/drawer/drawer-content.svelte
Normal file
33
web/src/lib/components/ui/drawer/drawer-content.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
/** 移动端 Sheet 的方向,默认 bottom */
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
let { children, class: className, side = 'bottom' }: Props = $props();
|
||||
|
||||
const isMobile = new IsMobile();
|
||||
</script>
|
||||
|
||||
{#if isMobile.current}
|
||||
<Sheet.Content
|
||||
{side}
|
||||
class={cn('max-h-[90vh] overflow-hidden flex flex-col', className)}
|
||||
>
|
||||
<!-- 拖拽指示器 (移动端抽屉常见设计) -->
|
||||
<div class="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-muted"></div>
|
||||
{@render children?.()}
|
||||
</Sheet.Content>
|
||||
{:else}
|
||||
<Dialog.Content class={cn('max-h-[85vh] overflow-hidden flex flex-col', className)}>
|
||||
{@render children?.()}
|
||||
</Dialog.Content>
|
||||
{/if}
|
||||
25
web/src/lib/components/ui/drawer/drawer-description.svelte
Normal file
25
web/src/lib/components/ui/drawer/drawer-description.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { children, class: className }: Props = $props();
|
||||
|
||||
const isMobile = new IsMobile();
|
||||
</script>
|
||||
|
||||
{#if isMobile.current}
|
||||
<Sheet.Description class={className}>
|
||||
{@render children?.()}
|
||||
</Sheet.Description>
|
||||
{:else}
|
||||
<Dialog.Description class={className}>
|
||||
{@render children?.()}
|
||||
</Dialog.Description>
|
||||
{/if}
|
||||
26
web/src/lib/components/ui/drawer/drawer-footer.svelte
Normal file
26
web/src/lib/components/ui/drawer/drawer-footer.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { children, class: className }: Props = $props();
|
||||
|
||||
const isMobile = new IsMobile();
|
||||
</script>
|
||||
|
||||
{#if isMobile.current}
|
||||
<Sheet.Footer class={cn('pt-2', className)}>
|
||||
{@render children?.()}
|
||||
</Sheet.Footer>
|
||||
{:else}
|
||||
<Dialog.Footer class={className}>
|
||||
{@render children?.()}
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
26
web/src/lib/components/ui/drawer/drawer-header.svelte
Normal file
26
web/src/lib/components/ui/drawer/drawer-header.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { children, class: className }: Props = $props();
|
||||
|
||||
const isMobile = new IsMobile();
|
||||
</script>
|
||||
|
||||
{#if isMobile.current}
|
||||
<Sheet.Header class={cn('text-left', className)}>
|
||||
{@render children?.()}
|
||||
</Sheet.Header>
|
||||
{:else}
|
||||
<Dialog.Header class={className}>
|
||||
{@render children?.()}
|
||||
</Dialog.Header>
|
||||
{/if}
|
||||
25
web/src/lib/components/ui/drawer/drawer-title.svelte
Normal file
25
web/src/lib/components/ui/drawer/drawer-title.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { children, class: className }: Props = $props();
|
||||
|
||||
const isMobile = new IsMobile();
|
||||
</script>
|
||||
|
||||
{#if isMobile.current}
|
||||
<Sheet.Title class={className}>
|
||||
{@render children?.()}
|
||||
</Sheet.Title>
|
||||
{:else}
|
||||
<Dialog.Title class={className}>
|
||||
{@render children?.()}
|
||||
</Dialog.Title>
|
||||
{/if}
|
||||
26
web/src/lib/components/ui/drawer/drawer.svelte
Normal file
26
web/src/lib/components/ui/drawer/drawer.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), onOpenChange, children }: Props = $props();
|
||||
|
||||
const isMobile = new IsMobile();
|
||||
</script>
|
||||
|
||||
{#if isMobile.current}
|
||||
<Sheet.Root bind:open {onOpenChange}>
|
||||
{@render children?.()}
|
||||
</Sheet.Root>
|
||||
{:else}
|
||||
<Dialog.Root bind:open {onOpenChange}>
|
||||
{@render children?.()}
|
||||
</Dialog.Root>
|
||||
{/if}
|
||||
25
web/src/lib/components/ui/drawer/index.ts
Normal file
25
web/src/lib/components/ui/drawer/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Root from './drawer.svelte';
|
||||
import Content from './drawer-content.svelte';
|
||||
import Header from './drawer-header.svelte';
|
||||
import Footer from './drawer-footer.svelte';
|
||||
import Title from './drawer-title.svelte';
|
||||
import Description from './drawer-description.svelte';
|
||||
import Close from './drawer-close.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Header,
|
||||
Footer,
|
||||
Title,
|
||||
Description,
|
||||
Close,
|
||||
//
|
||||
Root as Drawer,
|
||||
Content as DrawerContent,
|
||||
Header as DrawerHeader,
|
||||
Footer as DrawerFooter,
|
||||
Title as DrawerTitle,
|
||||
Description as DrawerDescription,
|
||||
Close as DrawerClose
|
||||
};
|
||||
19
web/src/lib/components/ui/popover/index.ts
Normal file
19
web/src/lib/components/ui/popover/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Root from "./popover.svelte";
|
||||
import Close from "./popover-close.svelte";
|
||||
import Content from "./popover-content.svelte";
|
||||
import Trigger from "./popover-trigger.svelte";
|
||||
import Portal from "./popover-portal.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
Close,
|
||||
Portal,
|
||||
//
|
||||
Root as Popover,
|
||||
Content as PopoverContent,
|
||||
Trigger as PopoverTrigger,
|
||||
Close as PopoverClose,
|
||||
Portal as PopoverPortal,
|
||||
};
|
||||
7
web/src/lib/components/ui/popover/popover-close.svelte
Normal file
7
web/src/lib/components/ui/popover/popover-close.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
|
||||
31
web/src/lib/components/ui/popover/popover-content.svelte
Normal file
31
web/src/lib/components/ui/popover/popover-content.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
import PopoverPortal from "./popover-portal.svelte";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
portalProps,
|
||||
...restProps
|
||||
}: PopoverPrimitive.ContentProps & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPortal {...portalProps}>
|
||||
<PopoverPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="popover-content"
|
||||
{sideOffset}
|
||||
{align}
|
||||
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 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</PopoverPortal>
|
||||
7
web/src/lib/components/ui/popover/popover-portal.svelte
Normal file
7
web/src/lib/components/ui/popover/popover-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Portal {...restProps} />
|
||||
17
web/src/lib/components/ui/popover/popover-trigger.svelte
Normal file
17
web/src/lib/components/ui/popover/popover-trigger.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: PopoverPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="popover-trigger"
|
||||
class={cn("", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
web/src/lib/components/ui/popover/popover.svelte
Normal file
7
web/src/lib/components/ui/popover/popover.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Root bind:open {...restProps} />
|
||||
40
web/src/lib/components/ui/range-calendar/index.ts
Normal file
40
web/src/lib/components/ui/range-calendar/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import Root from "./range-calendar.svelte";
|
||||
import Cell from "./range-calendar-cell.svelte";
|
||||
import Day from "./range-calendar-day.svelte";
|
||||
import Grid from "./range-calendar-grid.svelte";
|
||||
import Header from "./range-calendar-header.svelte";
|
||||
import Months from "./range-calendar-months.svelte";
|
||||
import GridRow from "./range-calendar-grid-row.svelte";
|
||||
import Heading from "./range-calendar-heading.svelte";
|
||||
import HeadCell from "./range-calendar-head-cell.svelte";
|
||||
import NextButton from "./range-calendar-next-button.svelte";
|
||||
import PrevButton from "./range-calendar-prev-button.svelte";
|
||||
import MonthSelect from "./range-calendar-month-select.svelte";
|
||||
import YearSelect from "./range-calendar-year-select.svelte";
|
||||
import Caption from "./range-calendar-caption.svelte";
|
||||
import Nav from "./range-calendar-nav.svelte";
|
||||
import Month from "./range-calendar-month.svelte";
|
||||
import GridBody from "./range-calendar-grid-body.svelte";
|
||||
import GridHead from "./range-calendar-grid-head.svelte";
|
||||
|
||||
export {
|
||||
Day,
|
||||
Cell,
|
||||
Grid,
|
||||
Header,
|
||||
Months,
|
||||
GridRow,
|
||||
Heading,
|
||||
GridBody,
|
||||
GridHead,
|
||||
HeadCell,
|
||||
NextButton,
|
||||
PrevButton,
|
||||
MonthSelect,
|
||||
YearSelect,
|
||||
Caption,
|
||||
Nav,
|
||||
Month,
|
||||
//
|
||||
Root as RangeCalendar,
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from "svelte";
|
||||
import type RangeCalendar from "./range-calendar.svelte";
|
||||
import RangeCalendarMonthSelect from "./range-calendar-month-select.svelte";
|
||||
import RangeCalendarYearSelect from "./range-calendar-year-select.svelte";
|
||||
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
|
||||
|
||||
let {
|
||||
captionLayout,
|
||||
months,
|
||||
monthFormat,
|
||||
years,
|
||||
yearFormat,
|
||||
month,
|
||||
locale,
|
||||
placeholder = $bindable(),
|
||||
monthIndex = 0,
|
||||
}: {
|
||||
captionLayout: ComponentProps<typeof RangeCalendar>["captionLayout"];
|
||||
months: ComponentProps<typeof RangeCalendarMonthSelect>["months"];
|
||||
monthFormat: ComponentProps<typeof RangeCalendarMonthSelect>["monthFormat"];
|
||||
years: ComponentProps<typeof RangeCalendarYearSelect>["years"];
|
||||
yearFormat: ComponentProps<typeof RangeCalendarYearSelect>["yearFormat"];
|
||||
month: DateValue;
|
||||
placeholder: DateValue | undefined;
|
||||
locale: string;
|
||||
monthIndex: number;
|
||||
} = $props();
|
||||
|
||||
function formatYear(date: DateValue) {
|
||||
const dateObj = date.toDate(getLocalTimeZone());
|
||||
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
|
||||
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
||||
}
|
||||
|
||||
function formatMonth(date: DateValue) {
|
||||
const dateObj = date.toDate(getLocalTimeZone());
|
||||
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
|
||||
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet MonthSelect()}
|
||||
<RangeCalendarMonthSelect
|
||||
{months}
|
||||
{monthFormat}
|
||||
value={month.month}
|
||||
onchange={(e) => {
|
||||
if (!placeholder) return;
|
||||
const v = Number.parseInt(e.currentTarget.value);
|
||||
const newPlaceholder = placeholder.set({ month: v });
|
||||
placeholder = newPlaceholder.subtract({ months: monthIndex });
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet YearSelect()}
|
||||
<RangeCalendarYearSelect {years} {yearFormat} value={month.year} />
|
||||
{/snippet}
|
||||
|
||||
{#if captionLayout === "dropdown"}
|
||||
{@render MonthSelect()}
|
||||
{@render YearSelect()}
|
||||
{:else if captionLayout === "dropdown-months"}
|
||||
{@render MonthSelect()}
|
||||
{#if placeholder}
|
||||
{formatYear(placeholder)}
|
||||
{/if}
|
||||
{:else if captionLayout === "dropdown-years"}
|
||||
{#if placeholder}
|
||||
{formatMonth(placeholder)}
|
||||
{/if}
|
||||
{@render YearSelect()}
|
||||
{:else}
|
||||
{formatMonth(month)} {formatYear(month)}
|
||||
{/if}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: RangeCalendarPrimitive.CellProps = $props();
|
||||
</script>
|
||||
|
||||
<RangeCalendarPrimitive.Cell
|
||||
bind:ref
|
||||
class={cn(
|
||||
"dark:[&:has([data-range-start])]:hover:bg-accent dark:[&:has([data-range-end])]:hover:bg-accent [&:has([data-range-middle])]:bg-accent dark:[&:has([data-range-middle])]:hover:bg-accent/50 [&:has([data-selected])]:bg-accent relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 data-[range-middle]:rounded-e-md [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:has([data-range-end])]:rounded-e-md [&:has([data-range-middle])]:rounded-none first:[&:has([data-range-middle])]:rounded-s-md last:[&:has([data-range-middle])]:rounded-e-md [&:has([data-range-start])]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: RangeCalendarPrimitive.DayProps = $props();
|
||||
</script>
|
||||
|
||||
<RangeCalendarPrimitive.Day
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none",
|
||||
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground data-[range-middle]:rounded-none",
|
||||
// range Start
|
||||
"data-[range-start]:bg-primary dark:data-[range-start]:hover:bg-accent data-[range-start]:text-primary-foreground",
|
||||
// range End
|
||||
"data-[range-end]:bg-primary dark:data-[range-end]:hover:bg-accent data-[range-end]:text-primary-foreground",
|
||||
// Outside months
|
||||
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
||||
// Disabled
|
||||
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
// Unavailable
|
||||
"data-[unavailable]:line-through",
|
||||
"dark:data-[range-middle]:hover:bg-accent/0",
|
||||
// hover
|
||||
"dark:hover:text-accent-foreground",
|
||||
// focus
|
||||
"focus:border-ring focus:ring-ring/50 focus:relative",
|
||||
// inner spans
|
||||
"[&>span]:text-xs [&>span]:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: RangeCalendarPrimitive.GridBodyProps = $props();
|
||||
</script>
|
||||
|
||||
<RangeCalendarPrimitive.GridBody bind:ref {...restProps} />
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: RangeCalendarPrimitive.GridHeadProps = $props();
|
||||
</script>
|
||||
|
||||
<RangeCalendarPrimitive.GridHead bind:ref {...restProps} />
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: RangeCalendarPrimitive.GridRowProps = $props();
|
||||
</script>
|
||||
|
||||
<RangeCalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: RangeCalendarPrimitive.GridProps = $props();
|
||||
</script>
|
||||
|
||||
<RangeCalendarPrimitive.Grid
|
||||
bind:ref
|
||||
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: RangeCalendarPrimitive.HeadCellProps = $props();
|
||||
</script>
|
||||
|
||||
<RangeCalendarPrimitive.HeadCell
|
||||
bind:ref
|
||||
class={cn(
|
||||
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: RangeCalendarPrimitive.HeaderProps = $props();
|
||||
</script>
|
||||
|
||||
<RangeCalendarPrimitive.Header
|
||||
bind:ref
|
||||
class={cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: RangeCalendarPrimitive.HeadingProps = $props();
|
||||
</script>
|
||||
|
||||
<RangeCalendarPrimitive.Heading
|
||||
bind:ref
|
||||
class={cn("px-(--cell-size) text-sm font-medium", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
onchange,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<RangeCalendarPrimitive.MonthSelectProps> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<RangeCalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||
{#snippet child({ props, monthItems, selectedMonthItem })}
|
||||
<select {...props} {value} {onchange}>
|
||||
{#each monthItems as monthItem (monthItem.value)}
|
||||
<option
|
||||
value={monthItem.value}
|
||||
selected={value !== undefined
|
||||
? monthItem.value === value
|
||||
: monthItem.value === selectedMonthItem.value}
|
||||
>
|
||||
{monthItem.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span
|
||||
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</RangeCalendarPrimitive.MonthSelect>
|
||||
</span>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { type WithElementRef, cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<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}
|
||||
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<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<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<nav
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</nav>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
variant = "ghost",
|
||||
...restProps
|
||||
}: RangeCalendarPrimitive.NextButtonProps & {
|
||||
variant?: ButtonVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronRightIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<RangeCalendarPrimitive.NextButton
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant }),
|
||||
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
|
||||
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
variant = "ghost",
|
||||
...restProps
|
||||
}: RangeCalendarPrimitive.PrevButtonProps & {
|
||||
variant?: ButtonVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronLeftIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<RangeCalendarPrimitive.PrevButton
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant }),
|
||||
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<RangeCalendarPrimitive.YearSelectProps> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<RangeCalendarPrimitive.YearSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||
{#snippet child({ props, yearItems, selectedYearItem })}
|
||||
<select {...props} {value}>
|
||||
{#each yearItems as yearItem (yearItem.value)}
|
||||
<option
|
||||
value={yearItem.value}
|
||||
selected={value !== undefined
|
||||
? yearItem.value === value
|
||||
: yearItem.value === selectedYearItem.value}
|
||||
>
|
||||
{yearItem.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span
|
||||
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</RangeCalendarPrimitive.YearSelect>
|
||||
</span>
|
||||
112
web/src/lib/components/ui/range-calendar/range-calendar.svelte
Normal file
112
web/src/lib/components/ui/range-calendar/range-calendar.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
|
||||
import * as RangeCalendar from "./index.js";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||
import type { Snippet } from "svelte";
|
||||
import { isEqualMonth, type DateValue } from "@internationalized/date";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
placeholder = $bindable(),
|
||||
weekdayFormat = "short",
|
||||
class: className,
|
||||
buttonVariant = "ghost",
|
||||
captionLayout = "label",
|
||||
locale = "en-US",
|
||||
months: monthsProp,
|
||||
years,
|
||||
monthFormat: monthFormatProp,
|
||||
yearFormat = "numeric",
|
||||
day,
|
||||
disableDaysOutsideMonth = false,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<RangeCalendarPrimitive.RootProps> & {
|
||||
buttonVariant?: ButtonVariant;
|
||||
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
|
||||
months?: RangeCalendarPrimitive.MonthSelectProps["months"];
|
||||
years?: RangeCalendarPrimitive.YearSelectProps["years"];
|
||||
monthFormat?: RangeCalendarPrimitive.MonthSelectProps["monthFormat"];
|
||||
yearFormat?: RangeCalendarPrimitive.YearSelectProps["yearFormat"];
|
||||
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
||||
} = $props();
|
||||
|
||||
const monthFormat = $derived.by(() => {
|
||||
if (monthFormatProp) return monthFormatProp;
|
||||
if (captionLayout.startsWith("dropdown")) return "short";
|
||||
return "long";
|
||||
});
|
||||
</script>
|
||||
|
||||
<RangeCalendarPrimitive.Root
|
||||
bind:ref
|
||||
bind:value
|
||||
bind:placeholder
|
||||
{weekdayFormat}
|
||||
{disableDaysOutsideMonth}
|
||||
class={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{locale}
|
||||
{monthFormat}
|
||||
{yearFormat}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ months, weekdays })}
|
||||
<RangeCalendar.Months>
|
||||
<RangeCalendar.Nav>
|
||||
<RangeCalendar.PrevButton variant={buttonVariant} />
|
||||
<RangeCalendar.NextButton variant={buttonVariant} />
|
||||
</RangeCalendar.Nav>
|
||||
{#each months as month, monthIndex (month)}
|
||||
<RangeCalendar.Month>
|
||||
<RangeCalendar.Header>
|
||||
<RangeCalendar.Caption
|
||||
{captionLayout}
|
||||
months={monthsProp}
|
||||
{monthFormat}
|
||||
{years}
|
||||
{yearFormat}
|
||||
month={month.value}
|
||||
bind:placeholder
|
||||
{locale}
|
||||
{monthIndex}
|
||||
/>
|
||||
</RangeCalendar.Header>
|
||||
|
||||
<RangeCalendar.Grid>
|
||||
<RangeCalendar.GridHead>
|
||||
<RangeCalendar.GridRow class="select-none">
|
||||
{#each weekdays as weekday (weekday)}
|
||||
<RangeCalendar.HeadCell>
|
||||
{weekday.slice(0, 2)}
|
||||
</RangeCalendar.HeadCell>
|
||||
{/each}
|
||||
</RangeCalendar.GridRow>
|
||||
</RangeCalendar.GridHead>
|
||||
<RangeCalendar.GridBody>
|
||||
{#each month.weeks as weekDates (weekDates)}
|
||||
<RangeCalendar.GridRow class="mt-2 w-full">
|
||||
{#each weekDates as date (date)}
|
||||
<RangeCalendar.Cell {date} month={month.value}>
|
||||
{#if day}
|
||||
{@render day({
|
||||
day: date,
|
||||
outsideMonth: !isEqualMonth(date, month.value),
|
||||
})}
|
||||
{:else}
|
||||
<RangeCalendar.Day />
|
||||
{/if}
|
||||
</RangeCalendar.Cell>
|
||||
{/each}
|
||||
</RangeCalendar.GridRow>
|
||||
{/each}
|
||||
</RangeCalendar.GridBody>
|
||||
</RangeCalendar.Grid>
|
||||
</RangeCalendar.Month>
|
||||
{/each}
|
||||
</RangeCalendar.Months>
|
||||
{/snippet}
|
||||
</RangeCalendarPrimitive.Root>
|
||||
Reference in New Issue
Block a user