@nucel/ui 0.3.0 → 0.10.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.
Files changed (78) hide show
  1. package/package.json +8 -36
  2. package/src/lib/components/BottomSheet.svelte +96 -0
  3. package/src/lib/components/Breadcrumbs.svelte +57 -0
  4. package/src/lib/components/Checkbox.svelte +64 -0
  5. package/src/lib/components/CodeBlock.svelte +264 -0
  6. package/src/lib/components/CodeEditor.svelte +175 -0
  7. package/src/lib/components/ColorInput.svelte +41 -0
  8. package/src/lib/components/ColorInput.test.ts +126 -0
  9. package/src/lib/components/Combobox.svelte +103 -0
  10. package/src/lib/components/CommandPalette.svelte +135 -0
  11. package/src/lib/components/CopyButton.svelte +95 -0
  12. package/src/lib/components/CopyButton.test.ts +213 -0
  13. package/src/lib/components/DataTable.svelte +202 -0
  14. package/src/lib/components/DateRangePicker.svelte +185 -0
  15. package/src/lib/components/DiffEditor.svelte +174 -0
  16. package/src/lib/components/Drawer.svelte +69 -0
  17. package/src/lib/components/Fab.svelte +59 -0
  18. package/src/lib/components/Form.svelte +38 -0
  19. package/src/lib/components/FormField.svelte +51 -0
  20. package/src/lib/components/IconButton.svelte +86 -0
  21. package/src/lib/components/IconButton.test.ts +139 -0
  22. package/src/lib/components/InlineCode.svelte +28 -0
  23. package/src/lib/components/Pagination.svelte +65 -0
  24. package/src/lib/components/Radio.svelte +60 -0
  25. package/src/lib/components/RadioGroup.svelte +26 -0
  26. package/src/lib/components/SearchInput.svelte +77 -0
  27. package/src/lib/components/Skeleton.svelte +76 -0
  28. package/src/lib/components/StatCard.svelte +97 -0
  29. package/src/lib/components/ThemeProvider.svelte +157 -0
  30. package/src/lib/components/ThemeToggle.svelte +68 -0
  31. package/src/lib/components/ThreeWayMerge.svelte +185 -0
  32. package/src/lib/components/ui/MarkdownRenderer.svelte +126 -8
  33. package/src/lib/components/ui/Sparkline.svelte +1 -1
  34. package/src/lib/components/ui/StatusBadge.svelte +6 -3
  35. package/src/lib/components/ui/StatusDot.svelte +3 -3
  36. package/src/lib/index.ts +113 -63
  37. package/src/lib/utils/cn.test.ts +993 -0
  38. package/src/lib/utils/detectLanguage.ts +187 -0
  39. package/src/lib/utils/monaco-workers.d.ts +32 -0
  40. package/src/lib/utils/monacoLoader.ts +167 -0
  41. package/src/lib/utils/shikiHighlighter.ts +78 -0
  42. package/src/styles.css +100 -32
  43. package/src/lib/components/ui/Alert.svelte +0 -47
  44. package/src/lib/components/ui/AppCard.svelte +0 -76
  45. package/src/lib/components/ui/AppShell.svelte +0 -14
  46. package/src/lib/components/ui/AppSidebar.svelte +0 -45
  47. package/src/lib/components/ui/BranchPill.svelte +0 -19
  48. package/src/lib/components/ui/CodeBlock.svelte +0 -92
  49. package/src/lib/components/ui/CommentPill.svelte +0 -12
  50. package/src/lib/components/ui/CopyButton.svelte +0 -43
  51. package/src/lib/components/ui/CostDisplay.svelte +0 -26
  52. package/src/lib/components/ui/FilterBar.svelte +0 -63
  53. package/src/lib/components/ui/FormField.svelte +0 -34
  54. package/src/lib/components/ui/KanbanBoard.svelte +0 -27
  55. package/src/lib/components/ui/KanbanCard.svelte +0 -43
  56. package/src/lib/components/ui/KanbanColumn.svelte +0 -52
  57. package/src/lib/components/ui/ListCard.svelte +0 -9
  58. package/src/lib/components/ui/MetricCard.svelte +0 -79
  59. package/src/lib/components/ui/NavItem.svelte +0 -42
  60. package/src/lib/components/ui/NavSection.svelte +0 -17
  61. package/src/lib/components/ui/PageHeader.svelte +0 -25
  62. package/src/lib/components/ui/Pagination.svelte +0 -85
  63. package/src/lib/components/ui/PermissionChips.svelte +0 -49
  64. package/src/lib/components/ui/Section.svelte +0 -21
  65. package/src/lib/components/ui/SectionTitle.svelte +0 -16
  66. package/src/lib/components/ui/StatCard.svelte +0 -19
  67. package/src/lib/components/ui/StatusPill.svelte +0 -54
  68. package/src/lib/components/ui/Timeline.svelte +0 -85
  69. package/src/lib/components/ui/editor/RichEditor.svelte +0 -580
  70. package/src/lib/components/ui/editor/mention-suggestion.ts +0 -144
  71. package/src/lib/components/ui/table/Table.svelte +0 -12
  72. package/src/lib/components/ui/table/TableBody.svelte +0 -10
  73. package/src/lib/components/ui/table/TableCaption.svelte +0 -10
  74. package/src/lib/components/ui/table/TableCell.svelte +0 -10
  75. package/src/lib/components/ui/table/TableHead.svelte +0 -10
  76. package/src/lib/components/ui/table/TableHeader.svelte +0 -10
  77. package/src/lib/components/ui/table/TableRow.svelte +0 -10
  78. package/src/lib/components/ui/table/index.ts +0 -7
@@ -0,0 +1,213 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { render } from '@testing-library/svelte';
3
+ import { tick } from 'svelte';
4
+ import CopyButton from './CopyButton.svelte';
5
+
6
+ /**
7
+ * Install a mock `navigator.clipboard.writeText`. jsdom does not provide a
8
+ * working clipboard, so we define it ourselves. Returns the spy.
9
+ */
10
+ function mockClipboard(impl?: (text: string) => Promise<void>) {
11
+ const writeText = vi.fn(impl ?? (() => Promise.resolve()));
12
+ Object.defineProperty(navigator, 'clipboard', {
13
+ value: { writeText },
14
+ configurable: true,
15
+ writable: true,
16
+ });
17
+ return writeText;
18
+ }
19
+
20
+ describe('CopyButton', () => {
21
+ beforeEach(() => {
22
+ vi.useFakeTimers();
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.runOnlyPendingTimers();
27
+ vi.useRealTimers();
28
+ vi.restoreAllMocks();
29
+ });
30
+
31
+ it('writes the value to the clipboard on click', async () => {
32
+ const writeText = mockClipboard();
33
+ const { getByRole } = render(CopyButton, { props: { value: 'hello-world' } });
34
+
35
+ await getByRole('button').click();
36
+ await tick();
37
+
38
+ expect(writeText).toHaveBeenCalledOnce();
39
+ expect(writeText).toHaveBeenCalledWith('hello-world');
40
+ });
41
+
42
+ it('shows the copied state after a successful copy and reverts after timeout', async () => {
43
+ mockClipboard();
44
+ const { getByRole } = render(CopyButton, {
45
+ props: { value: 'x', label: 'Copy', copiedLabel: 'Copied', timeout: 2000 },
46
+ });
47
+ const btn = getByRole('button');
48
+
49
+ expect(btn).toHaveTextContent('Copy');
50
+ expect(btn).not.toHaveAttribute('data-copied');
51
+
52
+ await btn.click();
53
+ await tick();
54
+
55
+ expect(btn).toHaveTextContent('Copied');
56
+ expect(btn).toHaveAttribute('data-copied');
57
+
58
+ // Advance past the timeout — state reverts.
59
+ vi.advanceTimersByTime(2000);
60
+ await tick();
61
+
62
+ expect(btn).toHaveTextContent('Copy');
63
+ expect(btn).not.toHaveAttribute('data-copied');
64
+ });
65
+
66
+ it('respects a custom timeout before reverting', async () => {
67
+ mockClipboard();
68
+ const { getByRole } = render(CopyButton, {
69
+ props: { value: 'x', label: 'Copy', copiedLabel: 'Copied', timeout: 5000 },
70
+ });
71
+ const btn = getByRole('button');
72
+
73
+ await btn.click();
74
+ await tick();
75
+ expect(btn).toHaveTextContent('Copied');
76
+
77
+ // Not yet elapsed at 2s.
78
+ vi.advanceTimersByTime(2000);
79
+ await tick();
80
+ expect(btn).toHaveTextContent('Copied');
81
+
82
+ // Elapsed at 5s total.
83
+ vi.advanceTimersByTime(3000);
84
+ await tick();
85
+ expect(btn).toHaveTextContent('Copy');
86
+ });
87
+
88
+ it('renders a visible label by default', () => {
89
+ mockClipboard();
90
+ const { getByRole } = render(CopyButton, { props: { value: 'x' } });
91
+ expect(getByRole('button')).toHaveTextContent('Copy');
92
+ });
93
+
94
+ it('renders icon-only (no text) when label is false, exposing an aria-label', () => {
95
+ mockClipboard();
96
+ const { getByRole } = render(CopyButton, { props: { value: 'x', label: false } });
97
+ const btn = getByRole('button');
98
+ // No visible "Copy" text node; accessible name comes from aria-label.
99
+ expect(btn).toHaveAttribute('aria-label', 'Copy');
100
+ expect(btn.querySelector('span')).toBeNull();
101
+ });
102
+
103
+ it('updates the icon-only aria-label to the copied label after copying', async () => {
104
+ mockClipboard();
105
+ const { getByRole } = render(CopyButton, {
106
+ props: { value: 'x', label: false, copiedLabel: 'Copied' },
107
+ });
108
+ const btn = getByRole('button');
109
+
110
+ await btn.click();
111
+ await tick();
112
+
113
+ expect(btn).toHaveAttribute('aria-label', 'Copied');
114
+ });
115
+
116
+ it('fires the onCopy callback with the value after a successful copy', async () => {
117
+ mockClipboard();
118
+ const onCopy = vi.fn();
119
+ const { getByRole } = render(CopyButton, { props: { value: 'token-123', onCopy } });
120
+
121
+ await getByRole('button').click();
122
+ await tick();
123
+
124
+ expect(onCopy).toHaveBeenCalledOnce();
125
+ expect(onCopy).toHaveBeenCalledWith('token-123');
126
+ });
127
+
128
+ it('fires onError and does NOT enter copied state when the write rejects', async () => {
129
+ const boom = new Error('permission denied');
130
+ mockClipboard(() => Promise.reject(boom));
131
+ const onError = vi.fn();
132
+ const onCopy = vi.fn();
133
+ const { getByRole } = render(CopyButton, {
134
+ props: { value: 'x', onError, onCopy },
135
+ });
136
+ const btn = getByRole('button');
137
+
138
+ await btn.click();
139
+ await tick();
140
+
141
+ expect(onError).toHaveBeenCalledOnce();
142
+ expect(onError).toHaveBeenCalledWith(boom);
143
+ expect(onCopy).not.toHaveBeenCalled();
144
+ expect(btn).not.toHaveAttribute('data-copied');
145
+ expect(btn).toHaveTextContent('Copy');
146
+ });
147
+
148
+ it('hides the icon when hideIcon is set', () => {
149
+ mockClipboard();
150
+ const { getByRole } = render(CopyButton, {
151
+ props: { value: 'x', hideIcon: true },
152
+ });
153
+ const btn = getByRole('button');
154
+ expect(btn.querySelector('svg')).toBeNull();
155
+ expect(btn).toHaveTextContent('Copy');
156
+ });
157
+
158
+ it('renders custom label and copiedLabel text', async () => {
159
+ mockClipboard();
160
+ const { getByRole } = render(CopyButton, {
161
+ props: { value: 'x', label: 'Copy URL', copiedLabel: 'Copied URL!' },
162
+ });
163
+ const btn = getByRole('button');
164
+ expect(btn).toHaveTextContent('Copy URL');
165
+
166
+ await btn.click();
167
+ await tick();
168
+ expect(btn).toHaveTextContent('Copied URL!');
169
+ });
170
+
171
+ it('does not copy or change state when disabled', async () => {
172
+ const writeText = mockClipboard();
173
+ const { getByRole } = render(CopyButton, {
174
+ props: { value: 'x', disabled: true },
175
+ });
176
+ const btn = getByRole('button');
177
+ expect(btn).toBeDisabled();
178
+
179
+ await btn.click();
180
+ await tick();
181
+
182
+ expect(writeText).not.toHaveBeenCalled();
183
+ expect(btn).not.toHaveAttribute('data-copied');
184
+ });
185
+
186
+ it('restarts the timer on a second click (debounced revert)', async () => {
187
+ mockClipboard();
188
+ const { getByRole } = render(CopyButton, {
189
+ props: { value: 'x', label: 'Copy', copiedLabel: 'Copied', timeout: 2000 },
190
+ });
191
+ const btn = getByRole('button');
192
+
193
+ await btn.click();
194
+ await tick();
195
+ expect(btn).toHaveTextContent('Copied');
196
+
197
+ // 1.5s in, click again — timer should reset.
198
+ vi.advanceTimersByTime(1500);
199
+ await btn.click();
200
+ await tick();
201
+ expect(btn).toHaveTextContent('Copied');
202
+
203
+ // 1s more (2.5s since first click, 1s since second) — still copied.
204
+ vi.advanceTimersByTime(1000);
205
+ await tick();
206
+ expect(btn).toHaveTextContent('Copied');
207
+
208
+ // Reach 2s since the second click — now reverts.
209
+ vi.advanceTimersByTime(1000);
210
+ await tick();
211
+ expect(btn).toHaveTextContent('Copy');
212
+ });
213
+ });
@@ -0,0 +1,202 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+
4
+ export type ColumnDef<T> = {
5
+ /** Column key — used for sorting and React-style row[key] access if accessor is omitted */
6
+ key: string;
7
+ /** Column header label */
8
+ header: string;
9
+ /** Optional cell value accessor — defaults to `row[key]` */
10
+ accessor?: (row: T) => unknown;
11
+ /** Optional custom cell renderer snippet receiving the row */
12
+ cell?: Snippet<[T]>;
13
+ /** Enable sorting on this column (defaults true) */
14
+ sortable?: boolean;
15
+ /** Optional column width (CSS) */
16
+ width?: string;
17
+ /** Optional alignment */
18
+ align?: 'left' | 'center' | 'right';
19
+ /** Extra class for both header and cells */
20
+ class?: string;
21
+ };
22
+
23
+ export type SortDirection = 'asc' | 'desc' | null;
24
+ </script>
25
+
26
+ <script lang="ts" generics="T">
27
+ import { ArrowUpIcon, ArrowDownIcon, ArrowUpDownIcon } from '@lucide/svelte';
28
+ import Pagination from './Pagination.svelte';
29
+ import { cn } from '../utils.js';
30
+
31
+ type Props = {
32
+ data: T[];
33
+ columns: ColumnDef<T>[];
34
+ pageSize?: number;
35
+ /** Initial page (1-indexed) */
36
+ page?: number;
37
+ /** Show pagination row (defaults true when data > pageSize) */
38
+ paginate?: boolean;
39
+ /** Optional row key fn — used for keyed-each */
40
+ rowKey?: (row: T, idx: number) => string | number;
41
+ /** Empty-state message */
42
+ emptyMessage?: string;
43
+ class?: string;
44
+ onRowClick?: (row: T) => void;
45
+ };
46
+
47
+ let {
48
+ data,
49
+ columns,
50
+ pageSize = 10,
51
+ page = $bindable(1),
52
+ paginate,
53
+ rowKey,
54
+ emptyMessage = 'No results',
55
+ class: className,
56
+ onRowClick,
57
+ }: Props = $props();
58
+
59
+ let sortKey = $state<string | null>(null);
60
+ let sortDir = $state<SortDirection>(null);
61
+
62
+ function getValue(row: T, col: ColumnDef<T>): unknown {
63
+ if (col.accessor) return col.accessor(row);
64
+ return (row as Record<string, unknown>)[col.key];
65
+ }
66
+
67
+ function compare(a: unknown, b: unknown): number {
68
+ if (a === b) return 0;
69
+ if (a === null || a === undefined) return -1;
70
+ if (b === null || b === undefined) return 1;
71
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
72
+ if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
73
+ return String(a).localeCompare(String(b));
74
+ }
75
+
76
+ function toggleSort(col: ColumnDef<T>) {
77
+ if (col.sortable === false) return;
78
+ if (sortKey !== col.key) {
79
+ sortKey = col.key;
80
+ sortDir = 'asc';
81
+ } else if (sortDir === 'asc') {
82
+ sortDir = 'desc';
83
+ } else if (sortDir === 'desc') {
84
+ sortKey = null;
85
+ sortDir = null;
86
+ } else {
87
+ sortDir = 'asc';
88
+ }
89
+ page = 1;
90
+ }
91
+
92
+ const sortedData = $derived.by(() => {
93
+ if (!sortKey || !sortDir) return data;
94
+ const col = columns.find((c) => c.key === sortKey);
95
+ if (!col) return data;
96
+ const dir = sortDir === 'asc' ? 1 : -1;
97
+ return [...data].sort((a, b) => compare(getValue(a, col), getValue(b, col)) * dir);
98
+ });
99
+
100
+ const total = $derived(sortedData.length);
101
+ const shouldPaginate = $derived(paginate ?? total > pageSize);
102
+
103
+ const pagedData = $derived.by(() => {
104
+ if (!shouldPaginate) return sortedData;
105
+ const start = (page - 1) * pageSize;
106
+ return sortedData.slice(start, start + pageSize);
107
+ });
108
+
109
+ function alignClass(a?: 'left' | 'center' | 'right') {
110
+ switch (a) {
111
+ case 'center':
112
+ return 'text-center';
113
+ case 'right':
114
+ return 'text-right';
115
+ default:
116
+ return 'text-left';
117
+ }
118
+ }
119
+ </script>
120
+
121
+ <div
122
+ data-slot="data-table"
123
+ class={cn('border-border bg-card overflow-hidden rounded-lg border', className)}
124
+ >
125
+ <div class="overflow-x-auto">
126
+ <table class="w-full caption-bottom text-sm">
127
+ <thead class="bg-muted/50 border-border border-b">
128
+ <tr>
129
+ {#each columns as col (col.key)}
130
+ <th
131
+ scope="col"
132
+ style={col.width ? `width:${col.width}` : undefined}
133
+ class={cn(
134
+ 'text-muted-foreground h-10 px-3 font-medium select-none',
135
+ alignClass(col.align),
136
+ col.class,
137
+ )}
138
+ >
139
+ {#if col.sortable !== false}
140
+ <button
141
+ type="button"
142
+ onclick={() => toggleSort(col)}
143
+ class="hover:text-foreground inline-flex items-center gap-1 outline-none focus-visible:ring-2 focus-visible:ring-ring/50 rounded"
144
+ aria-label={`Sort by ${col.header}`}
145
+ >
146
+ <span>{col.header}</span>
147
+ {#if sortKey === col.key && sortDir === 'asc'}
148
+ <ArrowUpIcon class="size-3" />
149
+ {:else if sortKey === col.key && sortDir === 'desc'}
150
+ <ArrowDownIcon class="size-3" />
151
+ {:else}
152
+ <ArrowUpDownIcon class="text-muted-foreground/50 size-3" />
153
+ {/if}
154
+ </button>
155
+ {:else}
156
+ <span>{col.header}</span>
157
+ {/if}
158
+ </th>
159
+ {/each}
160
+ </tr>
161
+ </thead>
162
+ <tbody>
163
+ {#if pagedData.length === 0}
164
+ <tr>
165
+ <td colspan={columns.length} class="text-muted-foreground py-8 text-center text-sm">
166
+ {emptyMessage}
167
+ </td>
168
+ </tr>
169
+ {:else}
170
+ {#each pagedData as row, i (rowKey ? rowKey(row, i) : i)}
171
+ <tr
172
+ class={cn(
173
+ 'border-border hover:bg-muted/40 border-b transition-colors last:border-b-0',
174
+ onRowClick && 'cursor-pointer',
175
+ )}
176
+ onclick={onRowClick ? () => onRowClick(row) : undefined}
177
+ >
178
+ {#each columns as col (col.key)}
179
+ <td class={cn('px-3 py-2.5 align-middle', alignClass(col.align), col.class)}>
180
+ {#if col.cell}
181
+ {@render col.cell(row)}
182
+ {:else}
183
+ {String(getValue(row, col) ?? '')}
184
+ {/if}
185
+ </td>
186
+ {/each}
187
+ </tr>
188
+ {/each}
189
+ {/if}
190
+ </tbody>
191
+ </table>
192
+ </div>
193
+ {#if shouldPaginate && total > 0}
194
+ <div class="border-border bg-muted/30 flex items-center justify-between gap-2 border-t px-3 py-2">
195
+ <span class="text-muted-foreground text-xs">
196
+ Showing {Math.min((page - 1) * pageSize + 1, total)}–{Math.min(page * pageSize, total)} of
197
+ {total}
198
+ </span>
199
+ <Pagination {total} bind:page {pageSize} />
200
+ </div>
201
+ {/if}
202
+ </div>
@@ -0,0 +1,185 @@
1
+ <script lang="ts" module>
2
+ import type { DateRange } from 'bits-ui';
3
+ export type { DateRange };
4
+
5
+ export type DateRangePreset = {
6
+ label: string;
7
+ /** Returns the [start, end] range (inclusive). end defaults to today. */
8
+ range: () => { start: Date; end: Date };
9
+ };
10
+
11
+ export const DEFAULT_PRESETS: DateRangePreset[] = [
12
+ {
13
+ label: 'Today',
14
+ range: () => {
15
+ const d = new Date();
16
+ return { start: d, end: d };
17
+ },
18
+ },
19
+ {
20
+ label: '7d',
21
+ range: () => {
22
+ const end = new Date();
23
+ const start = new Date();
24
+ start.setDate(end.getDate() - 6);
25
+ return { start, end };
26
+ },
27
+ },
28
+ {
29
+ label: '30d',
30
+ range: () => {
31
+ const end = new Date();
32
+ const start = new Date();
33
+ start.setDate(end.getDate() - 29);
34
+ return { start, end };
35
+ },
36
+ },
37
+ ];
38
+ </script>
39
+
40
+ <script lang="ts">
41
+ import { RangeCalendar as RangeCalendarPrimitive } from 'bits-ui';
42
+ import { CalendarDate, getLocalTimeZone, today } from '@internationalized/date';
43
+ import { ChevronLeftIcon, ChevronRightIcon } from '@lucide/svelte';
44
+ import { cn } from '../utils.js';
45
+
46
+ type Props = {
47
+ value?: DateRange;
48
+ numberOfMonths?: number;
49
+ presets?: DateRangePreset[];
50
+ showPresets?: boolean;
51
+ locale?: string;
52
+ class?: string;
53
+ onValueChange?: (range: DateRange) => void;
54
+ };
55
+
56
+ let {
57
+ value = $bindable<DateRange>({ start: undefined, end: undefined }),
58
+ numberOfMonths = 2,
59
+ presets = DEFAULT_PRESETS,
60
+ showPresets = true,
61
+ locale = 'en-US',
62
+ class: className,
63
+ onValueChange,
64
+ }: Props = $props();
65
+
66
+ function toCalendarDate(d: Date): CalendarDate {
67
+ return new CalendarDate(d.getFullYear(), d.getMonth() + 1, d.getDate());
68
+ }
69
+
70
+ function applyPreset(p: DateRangePreset) {
71
+ const r = p.range();
72
+ const next: DateRange = {
73
+ start: toCalendarDate(r.start),
74
+ end: toCalendarDate(r.end),
75
+ };
76
+ value = next;
77
+ onValueChange?.(next);
78
+ }
79
+
80
+ function isPresetActive(p: DateRangePreset): boolean {
81
+ if (!value?.start || !value?.end) return false;
82
+ const r = p.range();
83
+ const sCal = toCalendarDate(r.start);
84
+ const eCal = toCalendarDate(r.end);
85
+ return value.start.compare(sCal) === 0 && value.end.compare(eCal) === 0;
86
+ }
87
+
88
+ const placeholder = today(getLocalTimeZone());
89
+
90
+ const cellBase =
91
+ 'group relative inline-flex size-9 items-center justify-center rounded-md p-0 text-sm font-normal outline-none aria-selected:opacity-100';
92
+ const dayClass = cn(
93
+ cellBase,
94
+ 'data-[selected]:bg-primary data-[selected]:text-primary-foreground',
95
+ 'data-[selection-start]:bg-primary data-[selection-start]:text-primary-foreground data-[selection-start]:rounded-l-md',
96
+ 'data-[selection-end]:bg-primary data-[selection-end]:text-primary-foreground data-[selection-end]:rounded-r-md',
97
+ 'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',
98
+ 'data-[outside-month]:text-muted-foreground/50',
99
+ 'data-[disabled]:text-muted-foreground/40 data-[disabled]:pointer-events-none',
100
+ 'hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50',
101
+ );
102
+ </script>
103
+
104
+ <div
105
+ data-slot="date-range-picker"
106
+ class={cn(
107
+ 'border-border bg-card text-card-foreground inline-flex flex-col gap-3 rounded-lg border p-3 shadow-sm',
108
+ className,
109
+ )}
110
+ >
111
+ {#if showPresets && presets.length > 0}
112
+ <div class="flex flex-wrap items-center gap-1.5">
113
+ {#each presets as p (p.label)}
114
+ <button
115
+ type="button"
116
+ onclick={() => applyPreset(p)}
117
+ class={cn(
118
+ 'inline-flex h-7 items-center rounded-full border px-2.5 text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
119
+ isPresetActive(p)
120
+ ? 'bg-primary text-primary-foreground border-primary'
121
+ : 'border-border bg-background hover:bg-accent hover:text-accent-foreground',
122
+ )}
123
+ >
124
+ {p.label}
125
+ </button>
126
+ {/each}
127
+ </div>
128
+ {/if}
129
+
130
+ <RangeCalendarPrimitive.Root
131
+ bind:value
132
+ {numberOfMonths}
133
+ {locale}
134
+ {placeholder}
135
+ {onValueChange}
136
+ weekdayFormat="short"
137
+ class="flex flex-col gap-2"
138
+ >
139
+ {#snippet children({ months, weekdays })}
140
+ <RangeCalendarPrimitive.Header class="flex items-center justify-between px-1">
141
+ <RangeCalendarPrimitive.PrevButton
142
+ class="hover:bg-accent inline-flex size-8 items-center justify-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
143
+ aria-label="Previous month"
144
+ >
145
+ <ChevronLeftIcon class="size-4" />
146
+ </RangeCalendarPrimitive.PrevButton>
147
+ <RangeCalendarPrimitive.Heading class="text-sm font-medium" />
148
+ <RangeCalendarPrimitive.NextButton
149
+ class="hover:bg-accent inline-flex size-8 items-center justify-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
150
+ aria-label="Next month"
151
+ >
152
+ <ChevronRightIcon class="size-4" />
153
+ </RangeCalendarPrimitive.NextButton>
154
+ </RangeCalendarPrimitive.Header>
155
+ <div class="flex flex-col gap-4 sm:flex-row">
156
+ {#each months as month (month.value.toString())}
157
+ <RangeCalendarPrimitive.Grid class="border-collapse">
158
+ <RangeCalendarPrimitive.GridHead>
159
+ <RangeCalendarPrimitive.GridRow class="flex">
160
+ {#each weekdays as wd (wd)}
161
+ <RangeCalendarPrimitive.HeadCell
162
+ class="text-muted-foreground w-9 text-[0.7rem] font-normal"
163
+ >
164
+ {wd.slice(0, 2)}
165
+ </RangeCalendarPrimitive.HeadCell>
166
+ {/each}
167
+ </RangeCalendarPrimitive.GridRow>
168
+ </RangeCalendarPrimitive.GridHead>
169
+ <RangeCalendarPrimitive.GridBody>
170
+ {#each month.weeks as week, wi (wi)}
171
+ <RangeCalendarPrimitive.GridRow class="mt-1 flex w-full">
172
+ {#each week as date (date.toString())}
173
+ <RangeCalendarPrimitive.Cell {date} month={month.value} class="p-0">
174
+ <RangeCalendarPrimitive.Day class={dayClass} />
175
+ </RangeCalendarPrimitive.Cell>
176
+ {/each}
177
+ </RangeCalendarPrimitive.GridRow>
178
+ {/each}
179
+ </RangeCalendarPrimitive.GridBody>
180
+ </RangeCalendarPrimitive.Grid>
181
+ {/each}
182
+ </div>
183
+ {/snippet}
184
+ </RangeCalendarPrimitive.Root>
185
+ </div>