yaxa-svelte 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -12
- package/dist/components/elements/MetricCard.svelte +228 -0
- package/dist/components/elements/MetricCard.svelte.d.ts +19 -0
- package/dist/components/forms/DatePicker.svelte +322 -0
- package/dist/components/forms/DatePicker.svelte.d.ts +17 -0
- package/dist/components/forms/DateRangePicker.svelte +397 -0
- package/dist/components/forms/DateRangePicker.svelte.d.ts +27 -0
- package/dist/components/forms/InputOTP.svelte +165 -0
- package/dist/components/forms/InputOTP.svelte.d.ts +17 -0
- package/dist/components/forms/ToggleGroup.svelte +151 -0
- package/dist/components/forms/ToggleGroup.svelte.d.ts +22 -0
- package/dist/components/layout/Footer.svelte +33 -7
- package/dist/components/layout/Footer.svelte.d.ts +1 -1
- package/dist/components/seo/Seo.svelte +15 -2
- package/dist/components/seo/Seo.svelte.d.ts +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.js +7 -2
- package/dist/site/config.d.ts +27 -1
- package/dist/site/config.js +60 -15
- package/dist/site/og.d.ts +1 -1
- package/dist/site/og.js +7 -3
- package/dist/site/schema.d.ts +34 -1
- package/dist/site/schema.js +54 -2
- package/dist/styles/yaxa.css +70 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ export default defineConfig({
|
|
|
68
68
|
|
|
69
69
|
### 3. Define Your Site Configuration
|
|
70
70
|
|
|
71
|
-
Create `src/site.config.ts` — your single source of truth for branding, metadata, and SEO:
|
|
71
|
+
Create `src/site.config.ts` — your single source of truth for branding, metadata, licensing, and SEO:
|
|
72
72
|
|
|
73
73
|
```ts
|
|
74
74
|
// src/site.config.ts
|
|
@@ -79,14 +79,20 @@ export const siteConfig = defineSiteConfig({
|
|
|
79
79
|
title: 'My Indie SaaS — Build Fast with SvelteKit',
|
|
80
80
|
description: 'Fast, beautiful, accessible web applications.',
|
|
81
81
|
url: 'https://my-app.com',
|
|
82
|
+
// Single-email mode: Solo devs can set one email for contact, support & DPO
|
|
83
|
+
email: 'hello@my-app.com',
|
|
84
|
+
project: {
|
|
85
|
+
license: 'MIT',
|
|
86
|
+
type: 'open-source',
|
|
87
|
+
repositoryUrl: 'https://github.com/my-org/my-app'
|
|
88
|
+
},
|
|
82
89
|
theme: {
|
|
83
90
|
accent: 'svelte', // 'svelte' | 'emerald' | 'amber' | 'sky' | 'violet' | 'rose' | 'indigo'
|
|
84
91
|
neutral: 'zinc', // 'zinc' | 'slate' | 'stone' | 'neutral'
|
|
85
92
|
defaultMode: 'dark'
|
|
86
93
|
},
|
|
87
94
|
socials: {
|
|
88
|
-
github: 'https://github.com/my-org/my-app'
|
|
89
|
-
twitter: '@my_app'
|
|
95
|
+
github: 'https://github.com/my-org/my-app'
|
|
90
96
|
}
|
|
91
97
|
});
|
|
92
98
|
```
|
|
@@ -219,15 +225,15 @@ export const POST = createPolarWebhookHandler();
|
|
|
219
225
|
|
|
220
226
|
## 🧩 Component Library
|
|
221
227
|
|
|
222
|
-
| Category
|
|
223
|
-
|
|
|
224
|
-
| **SaaS Suite**
|
|
225
|
-
| **Admin Suite**
|
|
226
|
-
| **Elements**
|
|
227
|
-
| **Forms**
|
|
228
|
-
| **Layout**
|
|
229
|
-
| **Overlays & Nav**
|
|
230
|
-
| **Composables**
|
|
228
|
+
| Category | Components |
|
|
229
|
+
| :------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
230
|
+
| **SaaS Suite** | `<Gate>`, `<AuthCard>`, `<UserMenu>`, `<PricingCard>`, `<PricingTable>`, `<SubscriptionCard>`, `useAuth`, `useGate`, `useUpload` |
|
|
231
|
+
| **Admin Suite** | `<AdminDashboard>`, `<RecordDrawer>`, `<DevSandbox>`, `<ImpersonationBanner>`, `createYaxaAdminHook`, `createDrizzleAdmin` |
|
|
232
|
+
| **Elements** | `Button`, `ButtonGroup`, `Badge`, `Avatar`, `AvatarGroup`, `DataTable`, `Chip`, `Meter`, `MetricCard`, `Kbd`, `Icon`, `Spinner`, `Progress`, `Skeleton`, `Link`, `Logo` |
|
|
233
|
+
| **Forms** | `Form`, `FormField`, `Input`, `InputOTP`, `Textarea`, `Checkbox`, `Switch`, `Select`, `RadioGroup`, `Slider`, `ColorPicker`, `ToggleGroup`, `DatePicker`, `DateRangePicker` |
|
|
234
|
+
| **Layout & Blocks** | `Container`, `Header`, `Footer`, `Section`, `Card`, `Divider`, `YaxaApp`, Page Blocks (Hero, SaaS Metrics, 2FA Security) |
|
|
235
|
+
| **Overlays & Nav** | `Tabs`, `Breadcrumb`, `Pagination`, `CommandPalette` (`⌘K`), `DropdownMenu`, `ContextMenu`, `Modal`, `Slideover`, `Popover`, `Tooltip`, `Alert`, `Accordion` |
|
|
236
|
+
| **Composables** | `useAuth`, `useGate`, `useUpload`, `useClipboard`, `useShortcuts`, `useColorMode`, `useToast`, `useMediaQuery`, `useDebounce` |
|
|
231
237
|
|
|
232
238
|
---
|
|
233
239
|
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import Icon, { type IconSource } from './Icon.svelte';
|
|
4
|
+
import Badge from './Badge.svelte';
|
|
5
|
+
import Skeleton from './Skeleton.svelte';
|
|
6
|
+
import { cn } from '../../utils/cn';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
title: string;
|
|
10
|
+
value: string | number;
|
|
11
|
+
change?: number;
|
|
12
|
+
changeType?: 'percent' | 'absolute';
|
|
13
|
+
changePeriod?: string;
|
|
14
|
+
icon?: IconSource;
|
|
15
|
+
sparkline?: number[];
|
|
16
|
+
sparklineColor?: 'primary' | 'success' | 'warning' | 'error' | 'neutral' | 'auto';
|
|
17
|
+
variant?: 'outline' | 'flat' | 'elevated';
|
|
18
|
+
loading?: boolean;
|
|
19
|
+
class?: string;
|
|
20
|
+
children?: Snippet;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let {
|
|
24
|
+
title,
|
|
25
|
+
value,
|
|
26
|
+
change,
|
|
27
|
+
changeType = 'percent',
|
|
28
|
+
changePeriod = 'vs last period',
|
|
29
|
+
icon,
|
|
30
|
+
sparkline,
|
|
31
|
+
sparklineColor = 'auto',
|
|
32
|
+
variant = 'outline',
|
|
33
|
+
loading = false,
|
|
34
|
+
class: className = '',
|
|
35
|
+
children
|
|
36
|
+
}: Props = $props();
|
|
37
|
+
|
|
38
|
+
let isPositive = $derived(change !== undefined && change >= 0);
|
|
39
|
+
let changeFormatted = $derived.by(() => {
|
|
40
|
+
if (change === undefined) return '';
|
|
41
|
+
const prefix = change >= 0 ? '+' : '';
|
|
42
|
+
return `${prefix}${change}${changeType === 'percent' ? '%' : ''}`;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let resolvedColor = $derived.by(() => {
|
|
46
|
+
if (sparklineColor !== 'auto') return sparklineColor;
|
|
47
|
+
if (change !== undefined) {
|
|
48
|
+
return change >= 0 ? 'success' : 'error';
|
|
49
|
+
}
|
|
50
|
+
return 'primary';
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// SVG Sparkline geometry calculation (viewBox: 0 0 120 36)
|
|
54
|
+
let sparklineData = $derived.by(() => {
|
|
55
|
+
if (!sparkline || sparkline.length < 2) return null;
|
|
56
|
+
const width = 120;
|
|
57
|
+
const height = 36;
|
|
58
|
+
const padding = 3;
|
|
59
|
+
|
|
60
|
+
const min = Math.min(...sparkline);
|
|
61
|
+
const max = Math.max(...sparkline);
|
|
62
|
+
const range = max === min ? 1 : max - min;
|
|
63
|
+
|
|
64
|
+
const points = sparkline.map((val, idx) => {
|
|
65
|
+
const x = (idx / (sparkline.length - 1)) * width;
|
|
66
|
+
const y = height - padding - ((val - min) / range) * (height - padding * 2);
|
|
67
|
+
return { x, y };
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const linePath = points.reduce(
|
|
71
|
+
(acc, pt, i) =>
|
|
72
|
+
i === 0
|
|
73
|
+
? `M ${pt.x.toFixed(1)} ${pt.y.toFixed(1)}`
|
|
74
|
+
: `${acc} L ${pt.x.toFixed(1)} ${pt.y.toFixed(1)}`,
|
|
75
|
+
''
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const firstX = points[0].x.toFixed(1);
|
|
79
|
+
const lastX = points[points.length - 1].x.toFixed(1);
|
|
80
|
+
const areaPath = `${linePath} L ${lastX} ${height} L ${firstX} ${height} Z`;
|
|
81
|
+
|
|
82
|
+
const lastPoint = points[points.length - 1];
|
|
83
|
+
|
|
84
|
+
return { linePath, areaPath, lastPoint };
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
let colorHex = $derived.by(() => {
|
|
88
|
+
switch (resolvedColor) {
|
|
89
|
+
case 'success':
|
|
90
|
+
return { stroke: '#10b981', fill: '#10b981' };
|
|
91
|
+
case 'error':
|
|
92
|
+
return { stroke: '#f43f5e', fill: '#f43f5e' };
|
|
93
|
+
case 'warning':
|
|
94
|
+
return { stroke: '#f59e0b', fill: '#f59e0b' };
|
|
95
|
+
case 'neutral':
|
|
96
|
+
return { stroke: '#71717a', fill: '#71717a' };
|
|
97
|
+
case 'primary':
|
|
98
|
+
default:
|
|
99
|
+
return { stroke: '#ff3e00', fill: '#ff3e00' };
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
let containerClasses = $derived.by(() => {
|
|
104
|
+
const base =
|
|
105
|
+
'relative overflow-hidden rounded-xl p-5 transition-all duration-200 bg-white dark:bg-neutral-900';
|
|
106
|
+
switch (variant) {
|
|
107
|
+
case 'flat':
|
|
108
|
+
return cn(base, 'bg-neutral-50/80 dark:bg-neutral-900/60 border-0', className);
|
|
109
|
+
case 'elevated':
|
|
110
|
+
return cn(
|
|
111
|
+
base,
|
|
112
|
+
'shadow-md hover:shadow-lg border border-neutral-200/80 dark:border-neutral-800',
|
|
113
|
+
className
|
|
114
|
+
);
|
|
115
|
+
case 'outline':
|
|
116
|
+
default:
|
|
117
|
+
return cn(
|
|
118
|
+
base,
|
|
119
|
+
'border border-neutral-200 dark:border-neutral-800 hover:border-neutral-300 dark:hover:border-neutral-700',
|
|
120
|
+
className
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
let uniqueId = $derived(`sparkline-${Math.random().toString(36).slice(2, 8)}`);
|
|
126
|
+
</script>
|
|
127
|
+
|
|
128
|
+
<div class={containerClasses}>
|
|
129
|
+
{#if loading}
|
|
130
|
+
<div class="space-y-3">
|
|
131
|
+
<div class="flex items-center justify-between">
|
|
132
|
+
<Skeleton class="h-4 w-28" />
|
|
133
|
+
<Skeleton class="h-8 w-8 rounded-lg" />
|
|
134
|
+
</div>
|
|
135
|
+
<Skeleton class="h-8 w-36" />
|
|
136
|
+
<div class="flex items-center gap-2 pt-1">
|
|
137
|
+
<Skeleton class="h-4 w-16" />
|
|
138
|
+
<Skeleton class="h-4 w-24" />
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
{:else}
|
|
142
|
+
<div class="flex items-start justify-between gap-2">
|
|
143
|
+
<span class="truncate text-sm font-medium text-neutral-500 dark:text-neutral-400">
|
|
144
|
+
{title}
|
|
145
|
+
</span>
|
|
146
|
+
{#if icon}
|
|
147
|
+
<div
|
|
148
|
+
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
|
|
149
|
+
>
|
|
150
|
+
<Icon name={icon} size="sm" />
|
|
151
|
+
</div>
|
|
152
|
+
{/if}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<div class="mt-2 flex items-baseline justify-between gap-4">
|
|
156
|
+
<div>
|
|
157
|
+
<div class="text-2xl font-bold tracking-tight text-neutral-900 dark:text-neutral-50">
|
|
158
|
+
{value}
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{#if change !== undefined || changePeriod}
|
|
162
|
+
<div class="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs">
|
|
163
|
+
{#if change !== undefined}
|
|
164
|
+
<Badge
|
|
165
|
+
color={isPositive ? 'success' : 'error'}
|
|
166
|
+
variant="soft"
|
|
167
|
+
size="sm"
|
|
168
|
+
class="gap-0.5 font-semibold"
|
|
169
|
+
>
|
|
170
|
+
<Icon
|
|
171
|
+
name={isPositive ? 'arrow-up-right' : 'arrow-down-right'}
|
|
172
|
+
size="xs"
|
|
173
|
+
class="inline-block"
|
|
174
|
+
/>
|
|
175
|
+
{changeFormatted}
|
|
176
|
+
</Badge>
|
|
177
|
+
{/if}
|
|
178
|
+
{#if changePeriod}
|
|
179
|
+
<span class="text-neutral-500 dark:text-neutral-400">
|
|
180
|
+
{changePeriod}
|
|
181
|
+
</span>
|
|
182
|
+
{/if}
|
|
183
|
+
</div>
|
|
184
|
+
{/if}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<!-- Sparkline Chart -->
|
|
188
|
+
{#if sparklineData}
|
|
189
|
+
<div class="flex h-10 w-24 shrink-0 items-center">
|
|
190
|
+
<svg
|
|
191
|
+
viewBox="0 0 120 36"
|
|
192
|
+
class="h-full w-full overflow-visible"
|
|
193
|
+
preserveAspectRatio="none"
|
|
194
|
+
>
|
|
195
|
+
<defs>
|
|
196
|
+
<linearGradient id={uniqueId} x1="0" y1="0" x2="0" y2="1">
|
|
197
|
+
<stop offset="0%" stop-color={colorHex.fill} stop-opacity="0.35" />
|
|
198
|
+
<stop offset="100%" stop-color={colorHex.fill} stop-opacity="0" />
|
|
199
|
+
</linearGradient>
|
|
200
|
+
</defs>
|
|
201
|
+
<path d={sparklineData.areaPath} fill="url(#{uniqueId})" />
|
|
202
|
+
<path
|
|
203
|
+
d={sparklineData.linePath}
|
|
204
|
+
fill="none"
|
|
205
|
+
stroke={colorHex.stroke}
|
|
206
|
+
stroke-width="2.2"
|
|
207
|
+
stroke-linecap="round"
|
|
208
|
+
stroke-linejoin="round"
|
|
209
|
+
/>
|
|
210
|
+
<circle
|
|
211
|
+
cx={sparklineData.lastPoint.x}
|
|
212
|
+
cy={sparklineData.lastPoint.y}
|
|
213
|
+
r="3"
|
|
214
|
+
fill={colorHex.stroke}
|
|
215
|
+
class="animate-pulse"
|
|
216
|
+
/>
|
|
217
|
+
</svg>
|
|
218
|
+
</div>
|
|
219
|
+
{/if}
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{#if children}
|
|
223
|
+
<div class="mt-4 border-t border-neutral-100 pt-3 dark:border-neutral-800">
|
|
224
|
+
{@render children()}
|
|
225
|
+
</div>
|
|
226
|
+
{/if}
|
|
227
|
+
{/if}
|
|
228
|
+
</div>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type IconSource } from './Icon.svelte';
|
|
3
|
+
interface Props {
|
|
4
|
+
title: string;
|
|
5
|
+
value: string | number;
|
|
6
|
+
change?: number;
|
|
7
|
+
changeType?: 'percent' | 'absolute';
|
|
8
|
+
changePeriod?: string;
|
|
9
|
+
icon?: IconSource;
|
|
10
|
+
sparkline?: number[];
|
|
11
|
+
sparklineColor?: 'primary' | 'success' | 'warning' | 'error' | 'neutral' | 'auto';
|
|
12
|
+
variant?: 'outline' | 'flat' | 'elevated';
|
|
13
|
+
loading?: boolean;
|
|
14
|
+
class?: string;
|
|
15
|
+
children?: Snippet;
|
|
16
|
+
}
|
|
17
|
+
declare const MetricCard: import("svelte").Component<Props, {}, "">;
|
|
18
|
+
type MetricCard = ReturnType<typeof MetricCard>;
|
|
19
|
+
export default MetricCard;
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Popover } from 'bits-ui';
|
|
3
|
+
import Icon from '../elements/Icon.svelte';
|
|
4
|
+
import { cn } from '../../utils/cn';
|
|
5
|
+
|
|
6
|
+
export interface DatePreset {
|
|
7
|
+
label: string;
|
|
8
|
+
getValue: () => Date;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
value?: string | Date;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
min?: string | Date;
|
|
15
|
+
max?: string | Date;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
presets?: DatePreset[];
|
|
18
|
+
class?: string;
|
|
19
|
+
onchange?: (date: Date | null, dateString: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
value = $bindable(),
|
|
24
|
+
placeholder = 'Select date',
|
|
25
|
+
min,
|
|
26
|
+
max,
|
|
27
|
+
disabled = false,
|
|
28
|
+
presets,
|
|
29
|
+
class: className = '',
|
|
30
|
+
onchange
|
|
31
|
+
}: Props = $props();
|
|
32
|
+
|
|
33
|
+
let open = $state(false);
|
|
34
|
+
|
|
35
|
+
// Parse input date
|
|
36
|
+
function toDate(d: string | Date | undefined): Date | null {
|
|
37
|
+
if (!d) return null;
|
|
38
|
+
if (d instanceof Date) return isNaN(d.getTime()) ? null : d;
|
|
39
|
+
const parsed = new Date(d);
|
|
40
|
+
return isNaN(parsed.getTime()) ? null : parsed;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatDateIso(d: Date): string {
|
|
44
|
+
const year = d.getFullYear();
|
|
45
|
+
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
46
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
47
|
+
return `${year}-${month}-${day}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatDisplay(d: Date | null): string {
|
|
51
|
+
if (!d) return '';
|
|
52
|
+
return d.toLocaleDateString('en-US', {
|
|
53
|
+
month: 'short',
|
|
54
|
+
day: 'numeric',
|
|
55
|
+
year: 'numeric'
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let selectedDate = $derived(toDate(value));
|
|
60
|
+
let viewDate = $state(new Date());
|
|
61
|
+
|
|
62
|
+
$effect(() => {
|
|
63
|
+
if (selectedDate) {
|
|
64
|
+
viewDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
let viewYear = $derived(viewDate.getFullYear());
|
|
69
|
+
let viewMonth = $derived(viewDate.getMonth());
|
|
70
|
+
|
|
71
|
+
const MONTH_NAMES = [
|
|
72
|
+
'January',
|
|
73
|
+
'February',
|
|
74
|
+
'March',
|
|
75
|
+
'April',
|
|
76
|
+
'May',
|
|
77
|
+
'June',
|
|
78
|
+
'July',
|
|
79
|
+
'August',
|
|
80
|
+
'September',
|
|
81
|
+
'October',
|
|
82
|
+
'November',
|
|
83
|
+
'December'
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
|
87
|
+
|
|
88
|
+
const defaultPresets: DatePreset[] = [
|
|
89
|
+
{ label: 'Today', getValue: () => new Date() },
|
|
90
|
+
{
|
|
91
|
+
label: 'Tomorrow',
|
|
92
|
+
getValue: () => new Date(Date.now() + 24 * 60 * 60 * 1000)
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
label: '+7 Days',
|
|
96
|
+
getValue: () => new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
|
97
|
+
}
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
let activePresets = $derived(presets || defaultPresets);
|
|
101
|
+
|
|
102
|
+
let calendarDays = $derived.by(() => {
|
|
103
|
+
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
|
104
|
+
const firstDayIndex = new Date(viewYear, viewMonth, 1).getDay();
|
|
105
|
+
const prevMonthDays = new Date(viewYear, viewMonth, 0).getDate();
|
|
106
|
+
|
|
107
|
+
const cells: Array<{
|
|
108
|
+
day: number;
|
|
109
|
+
monthOffset: number; // -1: prev, 0: current, 1: next
|
|
110
|
+
date: Date;
|
|
111
|
+
isSelected: boolean;
|
|
112
|
+
isToday: boolean;
|
|
113
|
+
isDisabled: boolean;
|
|
114
|
+
}> = [];
|
|
115
|
+
|
|
116
|
+
const minDate = toDate(min);
|
|
117
|
+
const maxDate = toDate(max);
|
|
118
|
+
const today = new Date();
|
|
119
|
+
|
|
120
|
+
// Prev month padding
|
|
121
|
+
for (let i = firstDayIndex - 1; i >= 0; i--) {
|
|
122
|
+
const day = prevMonthDays - i;
|
|
123
|
+
const d = new Date(viewYear, viewMonth - 1, day);
|
|
124
|
+
cells.push({
|
|
125
|
+
day,
|
|
126
|
+
monthOffset: -1,
|
|
127
|
+
date: d,
|
|
128
|
+
isSelected: Boolean(selectedDate && formatDateIso(d) === formatDateIso(selectedDate)),
|
|
129
|
+
isToday: formatDateIso(d) === formatDateIso(today),
|
|
130
|
+
isDisabled: Boolean((minDate && d < minDate) || (maxDate && d > maxDate))
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Current month
|
|
135
|
+
for (let day = 1; day <= daysInMonth; day++) {
|
|
136
|
+
const d = new Date(viewYear, viewMonth, day);
|
|
137
|
+
cells.push({
|
|
138
|
+
day,
|
|
139
|
+
monthOffset: 0,
|
|
140
|
+
date: d,
|
|
141
|
+
isSelected: Boolean(selectedDate && formatDateIso(d) === formatDateIso(selectedDate)),
|
|
142
|
+
isToday: formatDateIso(d) === formatDateIso(today),
|
|
143
|
+
isDisabled: Boolean((minDate && d < minDate) || (maxDate && d > maxDate))
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Next month padding to fill complete weeks
|
|
148
|
+
const remaining = (7 - (cells.length % 7)) % 7;
|
|
149
|
+
for (let day = 1; day <= remaining; day++) {
|
|
150
|
+
const d = new Date(viewYear, viewMonth + 1, day);
|
|
151
|
+
cells.push({
|
|
152
|
+
day,
|
|
153
|
+
monthOffset: 1,
|
|
154
|
+
date: d,
|
|
155
|
+
isSelected: Boolean(selectedDate && formatDateIso(d) === formatDateIso(selectedDate)),
|
|
156
|
+
isToday: formatDateIso(d) === formatDateIso(today),
|
|
157
|
+
isDisabled: Boolean((minDate && d < minDate) || (maxDate && d > maxDate))
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return cells;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
function prevMonth() {
|
|
165
|
+
viewDate = new Date(viewYear, viewMonth - 1, 1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function nextMonth() {
|
|
169
|
+
viewDate = new Date(viewYear, viewMonth + 1, 1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function selectDay(date: Date) {
|
|
173
|
+
const iso = formatDateIso(date);
|
|
174
|
+
if (value instanceof Date) {
|
|
175
|
+
value = date;
|
|
176
|
+
} else {
|
|
177
|
+
value = iso;
|
|
178
|
+
}
|
|
179
|
+
onchange?.(date, iso);
|
|
180
|
+
open = false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function applyPreset(preset: DatePreset) {
|
|
184
|
+
const date = preset.getValue();
|
|
185
|
+
viewDate = new Date(date.getFullYear(), date.getMonth(), 1);
|
|
186
|
+
selectDay(date);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function clearDate() {
|
|
190
|
+
value = undefined;
|
|
191
|
+
onchange?.(null, '');
|
|
192
|
+
open = false;
|
|
193
|
+
}
|
|
194
|
+
</script>
|
|
195
|
+
|
|
196
|
+
<Popover.Root bind:open>
|
|
197
|
+
<Popover.Trigger
|
|
198
|
+
{disabled}
|
|
199
|
+
class={cn(
|
|
200
|
+
'inline-flex items-center justify-between gap-2.5 rounded-lg border px-3.5 py-2 text-sm font-medium transition-all duration-150 outline-none',
|
|
201
|
+
'border-neutral-300 bg-white text-neutral-900 hover:border-neutral-400 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20',
|
|
202
|
+
'dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:border-neutral-600 dark:focus:border-primary-400 dark:focus:ring-primary-400/20',
|
|
203
|
+
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
|
|
204
|
+
className
|
|
205
|
+
)}
|
|
206
|
+
>
|
|
207
|
+
<div class="inline-flex items-center gap-2 truncate">
|
|
208
|
+
<Icon name="calendar" size="sm" class="shrink-0 text-neutral-500 dark:text-neutral-400" />
|
|
209
|
+
<span
|
|
210
|
+
class={selectedDate
|
|
211
|
+
? 'font-semibold text-neutral-900 dark:text-neutral-100'
|
|
212
|
+
: 'text-neutral-500 dark:text-neutral-400'}
|
|
213
|
+
>
|
|
214
|
+
{selectedDate ? formatDisplay(selectedDate) : placeholder}
|
|
215
|
+
</span>
|
|
216
|
+
</div>
|
|
217
|
+
<Icon name="chevron-down" size="xs" class="shrink-0 text-neutral-400 dark:text-neutral-500" />
|
|
218
|
+
</Popover.Trigger>
|
|
219
|
+
|
|
220
|
+
<Popover.Portal>
|
|
221
|
+
<Popover.Content
|
|
222
|
+
class="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 z-50 w-72 rounded-xl border border-neutral-200 bg-white p-3 shadow-xl duration-150 outline-none dark:border-neutral-800 dark:bg-neutral-900"
|
|
223
|
+
sideOffset={6}
|
|
224
|
+
>
|
|
225
|
+
<!-- Presets Bar -->
|
|
226
|
+
{#if activePresets.length > 0}
|
|
227
|
+
<div
|
|
228
|
+
class="mb-3 flex flex-wrap gap-1 border-b border-neutral-100 pb-2.5 dark:border-neutral-800"
|
|
229
|
+
>
|
|
230
|
+
{#each activePresets as preset}
|
|
231
|
+
<button
|
|
232
|
+
type="button"
|
|
233
|
+
onclick={() => applyPreset(preset)}
|
|
234
|
+
class="rounded-md bg-neutral-100 px-2 py-1 text-xs font-medium text-neutral-700 transition-colors hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
|
235
|
+
>
|
|
236
|
+
{preset.label}
|
|
237
|
+
</button>
|
|
238
|
+
{/each}
|
|
239
|
+
</div>
|
|
240
|
+
{/if}
|
|
241
|
+
|
|
242
|
+
<!-- Month / Year Navigation -->
|
|
243
|
+
<div class="flex items-center justify-between px-1 py-1">
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onclick={prevMonth}
|
|
247
|
+
class="inline-flex h-7 w-7 items-center justify-center rounded-lg text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
|
|
248
|
+
aria-label="Previous month"
|
|
249
|
+
>
|
|
250
|
+
<Icon name="chevron-left" size="sm" />
|
|
251
|
+
</button>
|
|
252
|
+
<span class="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
|
253
|
+
{MONTH_NAMES[viewMonth]}
|
|
254
|
+
{viewYear}
|
|
255
|
+
</span>
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
onclick={nextMonth}
|
|
259
|
+
class="inline-flex h-7 w-7 items-center justify-center rounded-lg text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
|
|
260
|
+
aria-label="Next month"
|
|
261
|
+
>
|
|
262
|
+
<Icon name="chevron-right" size="sm" />
|
|
263
|
+
</button>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<!-- Weekday Headers -->
|
|
267
|
+
<div class="mt-2 grid grid-cols-7 gap-1 text-center">
|
|
268
|
+
{#each WEEKDAYS as day}
|
|
269
|
+
<span class="py-0.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">
|
|
270
|
+
{day}
|
|
271
|
+
</span>
|
|
272
|
+
{/each}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<!-- Calendar Matrix -->
|
|
276
|
+
<div class="mt-1 grid grid-cols-7 gap-1">
|
|
277
|
+
{#each calendarDays as cell}
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
disabled={cell.isDisabled}
|
|
281
|
+
onclick={() => selectDay(cell.date)}
|
|
282
|
+
class={cn(
|
|
283
|
+
'inline-flex h-8 w-8 items-center justify-center rounded-lg text-xs font-medium transition-all duration-100',
|
|
284
|
+
cell.monthOffset !== 0
|
|
285
|
+
? 'text-neutral-400 dark:text-neutral-600'
|
|
286
|
+
: 'text-neutral-900 dark:text-neutral-100',
|
|
287
|
+
cell.isToday && !cell.isSelected
|
|
288
|
+
? 'border border-primary-500 font-bold text-primary-600 dark:text-primary-400'
|
|
289
|
+
: '',
|
|
290
|
+
cell.isSelected
|
|
291
|
+
? 'bg-primary-600 font-bold text-white shadow-xs hover:bg-primary-500 dark:bg-primary-500 dark:hover:bg-primary-400'
|
|
292
|
+
: 'hover:bg-neutral-100 dark:hover:bg-neutral-800',
|
|
293
|
+
cell.isDisabled
|
|
294
|
+
? 'cursor-not-allowed opacity-30 hover:bg-transparent'
|
|
295
|
+
: 'cursor-pointer'
|
|
296
|
+
)}
|
|
297
|
+
>
|
|
298
|
+
{cell.day}
|
|
299
|
+
</button>
|
|
300
|
+
{/each}
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
<!-- Footer -->
|
|
304
|
+
{#if selectedDate}
|
|
305
|
+
<div
|
|
306
|
+
class="mt-3 flex items-center justify-between border-t border-neutral-100 pt-2 dark:border-neutral-800"
|
|
307
|
+
>
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
onclick={clearDate}
|
|
311
|
+
class="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
|
|
312
|
+
>
|
|
313
|
+
Clear
|
|
314
|
+
</button>
|
|
315
|
+
<span class="text-xs text-neutral-500 dark:text-neutral-400">
|
|
316
|
+
{formatDisplay(selectedDate)}
|
|
317
|
+
</span>
|
|
318
|
+
</div>
|
|
319
|
+
{/if}
|
|
320
|
+
</Popover.Content>
|
|
321
|
+
</Popover.Portal>
|
|
322
|
+
</Popover.Root>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface DatePreset {
|
|
2
|
+
label: string;
|
|
3
|
+
getValue: () => Date;
|
|
4
|
+
}
|
|
5
|
+
interface Props {
|
|
6
|
+
value?: string | Date;
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
min?: string | Date;
|
|
9
|
+
max?: string | Date;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
presets?: DatePreset[];
|
|
12
|
+
class?: string;
|
|
13
|
+
onchange?: (date: Date | null, dateString: string) => void;
|
|
14
|
+
}
|
|
15
|
+
declare const DatePicker: import("svelte").Component<Props, {}, "value">;
|
|
16
|
+
type DatePicker = ReturnType<typeof DatePicker>;
|
|
17
|
+
export default DatePicker;
|