@kahitsan/ksui 0.11.0 → 0.13.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 +19 -0
- package/package.json +18 -4
- package/src/components/base/DataTable.tsx +19 -29
- package/src/components/base/DatePicker.tsx +990 -0
- package/src/index.ts +16 -0
- package/src/utils/confirm.tsx +1 -1
- package/src/utils/parse-date.ts +595 -0
- package/tailwind.js +277 -0
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
// Source: kserp src/components/ui/DatePicker/DatePicker.tsx (the host's Tailwind-
|
|
2
|
+
// classed picker). Ported into ksui as a DOMAIN-FREE base primitive: a trigger
|
|
3
|
+
// button labeled with the selected date (or "Pick date"), a calendar popover
|
|
4
|
+
// (month grid, prev/next nav, day selection), single-date AND range mode, an
|
|
5
|
+
// optional natural-language text input + quick-options, an optional time field,
|
|
6
|
+
// and an optional time toggle. The value/onChange contract matches the host
|
|
7
|
+
// exactly (single `string | null`, or a `DateRangeValue { start, end }`).
|
|
8
|
+
//
|
|
9
|
+
// Like Button / Modal / DataTable, ksui ships no sidecar .css — every Tailwind
|
|
10
|
+
// utility the host used is reproduced here as a runtime <style> tag (a stable
|
|
11
|
+
// id, injected once per page) referenced with plain, unscoped `ksui-datepicker-*`
|
|
12
|
+
// class names. Surface / border / accent colors read from the SAME `--ksui-dt-*`
|
|
13
|
+
// CSS custom properties the DataTable uses (so the two share one palette and a
|
|
14
|
+
// retint flows to both), plus a few picker-specific `--ksui-dp-*` vars; the
|
|
15
|
+
// fallback after each `var(...)` is the host's exact zinc + amber value.
|
|
16
|
+
//
|
|
17
|
+
// The host's `DatePickerURLSync` child (which read `?date=` via @solidjs/router)
|
|
18
|
+
// is intentionally dropped: ksui depends only on solid-js + lucide-solid, never
|
|
19
|
+
// the router. Callers that want URL persistence wire it from the outside.
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
createSignal,
|
|
23
|
+
createMemo,
|
|
24
|
+
createEffect,
|
|
25
|
+
on,
|
|
26
|
+
For,
|
|
27
|
+
Show,
|
|
28
|
+
onMount,
|
|
29
|
+
onCleanup,
|
|
30
|
+
} from "solid-js";
|
|
31
|
+
import { Portal } from "solid-js/web";
|
|
32
|
+
import Calendar from "lucide-solid/icons/calendar";
|
|
33
|
+
import ChevronLeft from "lucide-solid/icons/chevron-left";
|
|
34
|
+
import ChevronRight from "lucide-solid/icons/chevron-right";
|
|
35
|
+
import Clock from "lucide-solid/icons/clock";
|
|
36
|
+
import X from "lucide-solid/icons/x";
|
|
37
|
+
import {
|
|
38
|
+
parseDateInput,
|
|
39
|
+
formatDateDisplay,
|
|
40
|
+
formatDateEditable,
|
|
41
|
+
formatTimeDisplay,
|
|
42
|
+
normalizeDate,
|
|
43
|
+
type ParsedDate,
|
|
44
|
+
} from "../../utils/parse-date";
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Injected CSS
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
//
|
|
50
|
+
// Shares the DataTable's `--ksui-dt-*` palette (accent / control-bg / border /
|
|
51
|
+
// fg / text / muted / faint), so wrapping a date filter and a table in the same
|
|
52
|
+
// retint container restyles both. Picker-only tones use `--ksui-dp-*`. Fallback
|
|
53
|
+
// after each `var(...)` = the host kserp DatePicker's exact Tailwind value.
|
|
54
|
+
//
|
|
55
|
+
// --ksui-dt-control-bg popover + trigger + input bg (zinc-900, #18181b)
|
|
56
|
+
// --ksui-dt-border borders / dividers (zinc-800/50, rgba(39,39,42,0.5))
|
|
57
|
+
// --ksui-dt-fg primary white-ish text (white, #ffffff)
|
|
58
|
+
// --ksui-dt-text secondary text (zinc-400, #a1a1aa)
|
|
59
|
+
// --ksui-dt-muted placeholder / day-header / hint (zinc-500, #71717a)
|
|
60
|
+
// --ksui-dt-faint "to" separator (zinc-600, #52525b)
|
|
61
|
+
// --ksui-dt-accent active accent text (amber-400, #fbbf24)
|
|
62
|
+
// --ksui-dt-accent-border focus ring (amber-500/50, rgba(245,158,11,0.5))
|
|
63
|
+
// --ksui-dp-trigger-active-bg active trigger bg (amber-600/10, rgba(217,119,6,0.1))
|
|
64
|
+
// --ksui-dp-trigger-active-border active trigger border (amber-500/40, rgba(245,158,11,0.4))
|
|
65
|
+
// --ksui-dp-trigger-active-text active trigger text (amber-400, #fbbf24)
|
|
66
|
+
// --ksui-dp-row-hover day-cell / nav hover bg (zinc-800/50, rgba(39,39,42,0.5))
|
|
67
|
+
// --ksui-dp-cell-text in-month day text (zinc-300, #d4d4d8)
|
|
68
|
+
// --ksui-dp-cell-out out-of-month day text (zinc-700, #3f3f46)
|
|
69
|
+
// --ksui-dp-sel-bg selected single-day bg (amber-600/30, rgba(217,119,6,0.3))
|
|
70
|
+
// --ksui-dp-range-end-bg range start/end bg (amber-600/40, rgba(217,119,6,0.4))
|
|
71
|
+
// --ksui-dp-range-mid-bg in-range bg (amber-500/15, rgba(245,158,11,0.15))
|
|
72
|
+
// --ksui-dp-preview-bg preview/hover bg (amber-500/10, rgba(245,158,11,0.1))
|
|
73
|
+
// --ksui-dp-accent-soft range/in-range/preview text (amber-300, #fcd34d)
|
|
74
|
+
// --ksui-dp-input-bg popover text-input bg (zinc-800/50, rgba(39,39,42,0.5))
|
|
75
|
+
// --ksui-dp-input-border popover text-input border (zinc-700/50, rgba(63,63,70,0.5))
|
|
76
|
+
// --ksui-dp-toggle-off off switch bg (zinc-700, #3f3f46)
|
|
77
|
+
// --ksui-dp-toggle-on on switch bg (amber-500/80, rgba(245,158,11,0.8))
|
|
78
|
+
// --ksui-dp-danger clear-hover / time-error text (red-400, #f87171)
|
|
79
|
+
// --ksui-dp-danger-border time-input invalid border (red-500/50, rgba(239,68,68,0.5))
|
|
80
|
+
//
|
|
81
|
+
const STYLE_ID = "ksui-datepicker-style";
|
|
82
|
+
|
|
83
|
+
const DATEPICKER_CSS = `
|
|
84
|
+
.ksui-datepicker{position:relative;display:flex;align-items:center;gap:0.25rem;}
|
|
85
|
+
.ksui-datepicker-trigger{display:inline-flex;cursor:pointer;align-items:center;gap:0.5rem;border-radius:0.5rem;border:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));background:var(--ksui-dt-control-bg,#18181b);padding:0.5rem 0.75rem;font-size:0.75rem;line-height:1rem;color:var(--ksui-dt-text,#a1a1aa);transition:color 0.15s ease,background-color 0.15s ease,border-color 0.15s ease;}
|
|
86
|
+
.ksui-datepicker-trigger:hover:not(:disabled){color:var(--ksui-dt-fg,#ffffff);}
|
|
87
|
+
.ksui-datepicker-trigger:disabled{cursor:not-allowed;opacity:0.5;}
|
|
88
|
+
.ksui-datepicker-trigger-active{border-color:var(--ksui-dp-trigger-active-border,rgba(245,158,11,0.4));background:var(--ksui-dp-trigger-active-bg,rgba(217,119,6,0.1));color:var(--ksui-dp-trigger-active-text,#fbbf24);}
|
|
89
|
+
.ksui-datepicker-clear-btn{border-radius:0.25rem;padding:0.25rem;color:var(--ksui-dt-muted,#71717a);background:transparent;border:0;cursor:pointer;display:inline-flex;transition:color 0.15s ease,background-color 0.15s ease;}
|
|
90
|
+
.ksui-datepicker-clear-btn:hover{background:var(--ksui-dp-row-hover,rgba(39,39,42,0.5));color:var(--ksui-dt-fg,#ffffff);}
|
|
91
|
+
.ksui-datepicker-popover{position:fixed;z-index:60;overflow-y:auto;border-radius:0.75rem;border:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));background:var(--ksui-dt-control-bg,#18181b);box-shadow:0 25px 50px -12px rgba(0,0,0,0.5);}
|
|
92
|
+
.ksui-datepicker-section{padding:0.75rem;}
|
|
93
|
+
.ksui-datepicker-section-bordered{border-bottom:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:0.75rem;}
|
|
94
|
+
.ksui-datepicker-input{width:100%;border-radius:0.5rem;border:1px solid var(--ksui-dp-input-border,rgba(63,63,70,0.5));background:var(--ksui-dp-input-bg,rgba(39,39,42,0.5));padding:0.5rem 0.75rem;font-size:0.875rem;line-height:1.25rem;color:var(--ksui-dt-fg,#ffffff);outline:none;transition:border-color 0.15s ease;}
|
|
95
|
+
.ksui-datepicker-input::placeholder{color:var(--ksui-dt-muted,#71717a);}
|
|
96
|
+
.ksui-datepicker-input:focus{border-color:var(--ksui-dt-accent-border,rgba(245,158,11,0.5));}
|
|
97
|
+
.ksui-datepicker-range-row{display:flex;align-items:center;gap:0.5rem;}
|
|
98
|
+
.ksui-datepicker-range-input{flex:1 1 0%;}
|
|
99
|
+
.ksui-datepicker-range-input-active{border-color:var(--ksui-dt-accent-border,rgba(245,158,11,0.5));}
|
|
100
|
+
.ksui-datepicker-range-sep{font-size:0.75rem;color:var(--ksui-dt-faint,#52525b);}
|
|
101
|
+
.ksui-datepicker-preview{margin-top:0.5rem;display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;}
|
|
102
|
+
.ksui-datepicker-preview-chip{border-radius:0.25rem;background:var(--ksui-dp-preview-bg,rgba(245,158,11,0.1));padding:0.125rem 0.5rem;color:var(--ksui-dt-accent,#fbbf24);}
|
|
103
|
+
.ksui-datepicker-preview-hint{color:var(--ksui-dt-muted,#71717a);}
|
|
104
|
+
.ksui-datepicker-quick{display:flex;gap:0.375rem;border-bottom:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:0.5rem 0.75rem;}
|
|
105
|
+
.ksui-datepicker-quick-btn{border-radius:0.375rem;padding:0.25rem 0.5rem;font-size:0.75rem;color:var(--ksui-dt-text,#a1a1aa);background:transparent;border:0;cursor:pointer;transition:background-color 0.15s ease,color 0.15s ease;}
|
|
106
|
+
.ksui-datepicker-quick-btn:hover{background:var(--ksui-dp-row-hover,rgba(39,39,42,0.5));color:var(--ksui-dt-fg,#ffffff);}
|
|
107
|
+
.ksui-datepicker-quick-btn-active{background:var(--ksui-dp-range-mid-bg,rgba(245,158,11,0.2));color:var(--ksui-dt-accent,#fbbf24);}
|
|
108
|
+
.ksui-datepicker-quick-btn-active:hover{background:var(--ksui-dp-range-mid-bg,rgba(245,158,11,0.2));color:var(--ksui-dt-accent,#fbbf24);}
|
|
109
|
+
.ksui-datepicker-nav{margin-bottom:0.5rem;display:flex;align-items:center;justify-content:space-between;}
|
|
110
|
+
.ksui-datepicker-nav-btn{border-radius:0.25rem;padding:0.25rem;color:var(--ksui-dt-text,#a1a1aa);background:transparent;border:0;cursor:pointer;display:inline-flex;transition:background-color 0.15s ease,color 0.15s ease;}
|
|
111
|
+
.ksui-datepicker-nav-btn:hover{background:var(--ksui-dp-row-hover,rgba(39,39,42,0.5));color:var(--ksui-dt-fg,#ffffff);}
|
|
112
|
+
.ksui-datepicker-month-label{font-size:0.875rem;font-weight:500;color:var(--ksui-dt-fg,#e4e4e7);}
|
|
113
|
+
.ksui-datepicker-dow{margin-bottom:0.25rem;display:grid;grid-template-columns:repeat(7,minmax(0,1fr));text-align:center;font-size:0.75rem;color:var(--ksui-dt-muted,#71717a);}
|
|
114
|
+
.ksui-datepicker-dow span{padding:0.25rem 0;}
|
|
115
|
+
.ksui-datepicker-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:1px;}
|
|
116
|
+
.ksui-datepicker-cell{border-radius:0.25rem;padding:0.375rem 0;text-align:center;font-size:0.75rem;color:var(--ksui-dp-cell-text,#d4d4d8);background:transparent;border:0;cursor:pointer;transition:background-color 0.15s ease,color 0.15s ease;}
|
|
117
|
+
.ksui-datepicker-cell:hover{background:var(--ksui-dp-row-hover,rgba(39,39,42,0.5));color:var(--ksui-dt-fg,#ffffff);}
|
|
118
|
+
.ksui-datepicker-cell-out{color:var(--ksui-dp-cell-out,#3f3f46);}
|
|
119
|
+
.ksui-datepicker-cell-out:hover{background:var(--ksui-dp-preview-bg,rgba(39,39,42,0.5));color:var(--ksui-dt-muted,#71717a);}
|
|
120
|
+
.ksui-datepicker-cell-today{font-weight:500;color:var(--ksui-dt-accent,#fbbf24);}
|
|
121
|
+
.ksui-datepicker-cell-selected{background:var(--ksui-dp-sel-bg,rgba(217,119,6,0.3));font-weight:500;color:var(--ksui-dt-accent,#fbbf24);}
|
|
122
|
+
.ksui-datepicker-cell-selected:hover{background:var(--ksui-dp-sel-bg,rgba(217,119,6,0.3));color:var(--ksui-dt-accent,#fbbf24);}
|
|
123
|
+
.ksui-datepicker-cell-preview{background:var(--ksui-dp-preview-bg,rgba(245,158,11,0.1));font-weight:500;color:var(--ksui-dp-accent-soft,#fcd34d);}
|
|
124
|
+
.ksui-datepicker-cell-range-end{background:var(--ksui-dp-range-end-bg,rgba(217,119,6,0.4));font-weight:500;color:var(--ksui-dp-accent-soft,#fcd34d);}
|
|
125
|
+
.ksui-datepicker-cell-range-end:hover{background:var(--ksui-dp-range-end-bg,rgba(217,119,6,0.4));color:var(--ksui-dp-accent-soft,#fcd34d);}
|
|
126
|
+
.ksui-datepicker-cell-in-range{background:var(--ksui-dp-range-mid-bg,rgba(245,158,11,0.15));color:var(--ksui-dp-accent-soft,#fcd34d);}
|
|
127
|
+
.ksui-datepicker-cell-in-range:hover{background:var(--ksui-dp-range-mid-bg,rgba(245,158,11,0.15));color:var(--ksui-dp-accent-soft,#fcd34d);}
|
|
128
|
+
.ksui-datepicker-cell-range-preview{background:var(--ksui-dp-preview-bg,rgba(245,158,11,0.1));color:var(--ksui-dp-accent-soft,#fcd34d);}
|
|
129
|
+
.ksui-datepicker-toggle-row{display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:0.5rem 0.75rem;font-size:0.75rem;color:var(--ksui-dt-fg,#d4d4d8);}
|
|
130
|
+
.ksui-datepicker-switch{position:relative;height:1rem;width:1.75rem;border-radius:9999px;padding:0.125rem;border:0;cursor:pointer;background:var(--ksui-dp-toggle-off,#3f3f46);transition:background-color 0.15s ease;}
|
|
131
|
+
.ksui-datepicker-switch-on{background:var(--ksui-dp-toggle-on,rgba(245,158,11,0.8));}
|
|
132
|
+
.ksui-datepicker-switch-knob{display:block;height:0.75rem;width:0.75rem;border-radius:9999px;background:#ffffff;transition:transform 0.15s ease;transform:translateX(0);}
|
|
133
|
+
.ksui-datepicker-switch-on .ksui-datepicker-switch-knob{transform:translateX(0.75rem);}
|
|
134
|
+
.ksui-datepicker-time-row{border-top:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:0.5rem 0.75rem;}
|
|
135
|
+
.ksui-datepicker-time-inner{display:flex;align-items:center;gap:0.5rem;}
|
|
136
|
+
.ksui-datepicker-time-icon{color:var(--ksui-dt-muted,#71717a);display:inline-flex;}
|
|
137
|
+
.ksui-datepicker-time-input{flex:1 1 0%;border-radius:0.25rem;border:1px solid var(--ksui-dp-input-border,rgba(63,63,70,0.5));background:var(--ksui-dp-input-bg,rgba(39,39,42,0.5));padding:0.25rem 0.5rem;font-size:0.75rem;color:var(--ksui-dt-fg,#ffffff);outline:none;transition:border-color 0.15s ease;}
|
|
138
|
+
.ksui-datepicker-time-input::placeholder{color:var(--ksui-dt-muted,#71717a);}
|
|
139
|
+
.ksui-datepicker-time-input:focus{border-color:var(--ksui-dt-accent-border,rgba(245,158,11,0.5));}
|
|
140
|
+
.ksui-datepicker-time-input-invalid{border-color:var(--ksui-dp-danger-border,rgba(239,68,68,0.5));}
|
|
141
|
+
.ksui-datepicker-time-clear{border-radius:0.25rem;padding:0.125rem;color:var(--ksui-dt-muted,#71717a);background:transparent;border:0;cursor:pointer;display:inline-flex;}
|
|
142
|
+
.ksui-datepicker-time-clear:hover{color:var(--ksui-dt-fg,#ffffff);}
|
|
143
|
+
.ksui-datepicker-time-error{margin-top:0.25rem;font-size:0.75rem;color:var(--ksui-dp-danger,#f87171);}
|
|
144
|
+
.ksui-datepicker-footer{display:flex;align-items:center;justify-content:flex-end;border-top:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:0.5rem 0.75rem;}
|
|
145
|
+
.ksui-datepicker-footer-clear{font-size:0.75rem;color:var(--ksui-dt-muted,#71717a);background:transparent;border:0;cursor:pointer;transition:color 0.15s ease;}
|
|
146
|
+
.ksui-datepicker-footer-clear:hover{color:var(--ksui-dp-danger,#f87171);}
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
function ensureDatePickerStyle(): void {
|
|
150
|
+
if (typeof document === "undefined") return;
|
|
151
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
152
|
+
const el = document.createElement("style");
|
|
153
|
+
el.id = STYLE_ID;
|
|
154
|
+
el.textContent = DATEPICKER_CSS;
|
|
155
|
+
document.head.appendChild(el);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Types
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export interface DateRangeValue {
|
|
163
|
+
start: string | null;
|
|
164
|
+
end: string | null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface DatePickerSharedProps {
|
|
168
|
+
placeholder?: string;
|
|
169
|
+
withTime?: boolean;
|
|
170
|
+
disabled?: boolean;
|
|
171
|
+
/** Override the trigger button class entirely (escape hatch for custom triggers). */
|
|
172
|
+
triggerClass?: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface DatePickerSingleProps extends DatePickerSharedProps {
|
|
176
|
+
range?: false;
|
|
177
|
+
value: string | null;
|
|
178
|
+
onChange: (date: string | null) => void;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface DatePickerRangeProps extends DatePickerSharedProps {
|
|
182
|
+
range: true;
|
|
183
|
+
value: DateRangeValue;
|
|
184
|
+
onChange: (range: DateRangeValue) => void;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps;
|
|
188
|
+
|
|
189
|
+
const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
|
190
|
+
|
|
191
|
+
function toDateStr(y: number, m: number, d: number): string {
|
|
192
|
+
return `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function todayStr(): string {
|
|
196
|
+
const d = new Date();
|
|
197
|
+
return toDateStr(d.getFullYear(), d.getMonth(), d.getDate());
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function offsetTodayStr(days: number): string {
|
|
201
|
+
const d = new Date();
|
|
202
|
+
d.setDate(d.getDate() - days);
|
|
203
|
+
return toDateStr(d.getFullYear(), d.getMonth(), d.getDate());
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function offsetTodayMonthStr(months: number): string {
|
|
207
|
+
const d = new Date();
|
|
208
|
+
d.setMonth(d.getMonth() - months);
|
|
209
|
+
return toDateStr(d.getFullYear(), d.getMonth(), d.getDate());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface SingleQuickOption {
|
|
213
|
+
label: string;
|
|
214
|
+
getDate: () => string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface RangeQuickOption {
|
|
218
|
+
label: string;
|
|
219
|
+
getRange: () => DateRangeValue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const QUICK_OPTIONS_SINGLE: SingleQuickOption[] = [
|
|
223
|
+
{ label: "Today", getDate: () => todayStr() },
|
|
224
|
+
{ label: "Yesterday", getDate: () => offsetTodayStr(1) },
|
|
225
|
+
{ label: "Last week", getDate: () => offsetTodayStr(7) },
|
|
226
|
+
{ label: "Last month", getDate: () => offsetTodayMonthStr(1) },
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
const QUICK_OPTIONS_RANGE: RangeQuickOption[] = [
|
|
230
|
+
{
|
|
231
|
+
label: "Today",
|
|
232
|
+
getRange: () => ({ start: todayStr(), end: todayStr() }),
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
label: "Yesterday",
|
|
236
|
+
getRange: () => {
|
|
237
|
+
const y = offsetTodayStr(1);
|
|
238
|
+
return { start: y, end: y };
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
label: "Last week",
|
|
243
|
+
getRange: () => ({ start: offsetTodayStr(6), end: todayStr() }),
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
label: "Last month",
|
|
247
|
+
getRange: () => ({ start: offsetTodayMonthStr(1), end: todayStr() }),
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Component
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
export default function DatePicker(props: DatePickerProps) {
|
|
256
|
+
ensureDatePickerStyle();
|
|
257
|
+
|
|
258
|
+
const isRange = (): boolean => (props as DatePickerProps).range === true;
|
|
259
|
+
|
|
260
|
+
const singleValue = (): string | null =>
|
|
261
|
+
isRange() ? null : ((props as DatePickerSingleProps).value ?? null);
|
|
262
|
+
const rangeValue = (): DateRangeValue =>
|
|
263
|
+
isRange()
|
|
264
|
+
? ((props as DatePickerRangeProps).value ?? { start: null, end: null })
|
|
265
|
+
: { start: null, end: null };
|
|
266
|
+
|
|
267
|
+
function emitSingle(value: string | null) {
|
|
268
|
+
(props as DatePickerSingleProps).onChange(value);
|
|
269
|
+
}
|
|
270
|
+
function emitRange(value: DateRangeValue) {
|
|
271
|
+
(props as DatePickerRangeProps).onChange(value);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const [open, setOpen] = createSignal(false);
|
|
275
|
+
const [inputValue, setInputValue] = createSignal("");
|
|
276
|
+
const [endInputValue, setEndInputValue] = createSignal("");
|
|
277
|
+
const [activeField, setActiveField] = createSignal<"start" | "end">("start");
|
|
278
|
+
const [preview, setPreview] = createSignal<ParsedDate | null>(null);
|
|
279
|
+
const [time, setTime] = createSignal<string | undefined>(undefined);
|
|
280
|
+
const [popoverPos, setPopoverPos] = createSignal({ top: 0, left: 0 });
|
|
281
|
+
const [hoverDate, setHoverDate] = createSignal<string | null>(null);
|
|
282
|
+
// When range mode is allowed, this reflects whether the user has actually
|
|
283
|
+
// toggled "End date" on. Stays false (single-date UI) until the user opts in.
|
|
284
|
+
const [endActive, setEndActive] = createSignal(false);
|
|
285
|
+
// Whether the calendar/inputs are currently behaving as a range picker.
|
|
286
|
+
const isRangeActive = (): boolean => isRange() && endActive();
|
|
287
|
+
// Single source of truth for the popover's numeric width. openPicker (initial
|
|
288
|
+
// position), reposition (on scroll/resize), and the rendered <div> all read
|
|
289
|
+
// from here so they cannot drift apart and clamp to different left
|
|
290
|
+
// coordinates when the trigger sits near the right edge of the viewport.
|
|
291
|
+
const popoverPxWidth = (): number => (isRangeActive() ? 360 : 320);
|
|
292
|
+
|
|
293
|
+
const today = new Date();
|
|
294
|
+
const [viewYear, setViewYear] = createSignal(today.getFullYear());
|
|
295
|
+
const [viewMonth, setViewMonth] = createSignal(today.getMonth());
|
|
296
|
+
|
|
297
|
+
let inputRef: HTMLInputElement | undefined;
|
|
298
|
+
let endInputRef: HTMLInputElement | undefined;
|
|
299
|
+
let popoverRef: HTMLDivElement | undefined;
|
|
300
|
+
// Separate ref for the portaled popover panel — popoverRef tracks the
|
|
301
|
+
// in-tree trigger wrapper, popoverPanelRef tracks the panel rendered into
|
|
302
|
+
// document.body. Both must be excluded from the outside-click handler;
|
|
303
|
+
// otherwise clicks inside the calendar grid would dismiss it because the
|
|
304
|
+
// wrapper no longer contains the portaled panel.
|
|
305
|
+
let popoverPanelRef: HTMLDivElement | undefined;
|
|
306
|
+
let triggerRef: HTMLButtonElement | undefined;
|
|
307
|
+
|
|
308
|
+
const todayStrConst = todayStr();
|
|
309
|
+
|
|
310
|
+
const normalizedSingle = createMemo(() => {
|
|
311
|
+
const v = singleValue();
|
|
312
|
+
return v ? normalizeDate(v) : null;
|
|
313
|
+
});
|
|
314
|
+
const normalizedStart = createMemo(() => {
|
|
315
|
+
const v = rangeValue().start;
|
|
316
|
+
return v ? normalizeDate(v) : null;
|
|
317
|
+
});
|
|
318
|
+
const normalizedEnd = createMemo(() => {
|
|
319
|
+
const v = rangeValue().end;
|
|
320
|
+
return v ? normalizeDate(v) : null;
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ── Calendar grid ──────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
const monthLabel = createMemo(() => {
|
|
326
|
+
const d = new Date(viewYear(), viewMonth(), 1);
|
|
327
|
+
return d.toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const calendarDays = createMemo(() => {
|
|
331
|
+
const y = viewYear();
|
|
332
|
+
const m = viewMonth();
|
|
333
|
+
const firstDay = new Date(y, m, 1).getDay();
|
|
334
|
+
const daysInMonth = new Date(y, m + 1, 0).getDate();
|
|
335
|
+
const daysInPrev = new Date(y, m, 0).getDate();
|
|
336
|
+
|
|
337
|
+
const cells: { day: number; dateStr: string; current: boolean }[] = [];
|
|
338
|
+
|
|
339
|
+
for (let i = firstDay - 1; i >= 0; i--) {
|
|
340
|
+
const d = daysInPrev - i;
|
|
341
|
+
const pm = m === 0 ? 11 : m - 1;
|
|
342
|
+
const py = m === 0 ? y - 1 : y;
|
|
343
|
+
cells.push({ day: d, dateStr: toDateStr(py, pm, d), current: false });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
for (let d = 1; d <= daysInMonth; d++) {
|
|
347
|
+
cells.push({ day: d, dateStr: toDateStr(y, m, d), current: true });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const remaining = 42 - cells.length;
|
|
351
|
+
for (let d = 1; d <= remaining; d++) {
|
|
352
|
+
const nm = m === 11 ? 0 : m + 1;
|
|
353
|
+
const ny = m === 11 ? y + 1 : y;
|
|
354
|
+
cells.push({ day: d, dateStr: toDateStr(ny, nm, d), current: false });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return cells;
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ── Navigation ─────────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
function prevMonth() {
|
|
363
|
+
if (viewMonth() === 0) {
|
|
364
|
+
setViewMonth(11);
|
|
365
|
+
setViewYear((y) => y - 1);
|
|
366
|
+
} else {
|
|
367
|
+
setViewMonth((m) => m - 1);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function nextMonth() {
|
|
372
|
+
if (viewMonth() === 11) {
|
|
373
|
+
setViewMonth(0);
|
|
374
|
+
setViewYear((y) => y + 1);
|
|
375
|
+
} else {
|
|
376
|
+
setViewMonth((m) => m + 1);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── Selection ──────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
function selectDateSingle(dateStr: string) {
|
|
383
|
+
emitSingle(dateStr);
|
|
384
|
+
setInputValue(formatDateEditable(dateStr));
|
|
385
|
+
setPreview(null);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function selectDateRange(dateStr: string) {
|
|
389
|
+
// Range-mode click rules:
|
|
390
|
+
// 1. The click only writes the field that's currently active. The other
|
|
391
|
+
// bound is preserved (e.g. picking a new start does NOT clobber an
|
|
392
|
+
// existing end). The previous version collapsed end onto start, which
|
|
393
|
+
// forced the user to re-pick end after every start change — that's
|
|
394
|
+
// what made the calendar look like it "lost" the end highlight.
|
|
395
|
+
// 2. If the new pick would invert the range (start > end after a start
|
|
396
|
+
// click, or end < start after an end click), swap the bounds so the
|
|
397
|
+
// stored range stays valid. Notion / Linear behave this way; the
|
|
398
|
+
// alternative (showing an invalid range or wiping the other bound) is
|
|
399
|
+
// worse for the user.
|
|
400
|
+
// 3. Focus and activeField never change here. The caller's input keeps
|
|
401
|
+
// its caret so they can keep typing or click another date.
|
|
402
|
+
const cur = rangeValue();
|
|
403
|
+
const field = activeField();
|
|
404
|
+
|
|
405
|
+
let nextStart = cur.start;
|
|
406
|
+
let nextEnd = cur.end;
|
|
407
|
+
|
|
408
|
+
if (field === "start") {
|
|
409
|
+
nextStart = dateStr;
|
|
410
|
+
if (nextEnd && nextStart > nextEnd) {
|
|
411
|
+
[nextStart, nextEnd] = [nextEnd, nextStart];
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
nextEnd = dateStr;
|
|
415
|
+
if (nextStart && nextEnd < nextStart) {
|
|
416
|
+
[nextStart, nextEnd] = [nextEnd, nextStart];
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
emitRange({ start: nextStart, end: nextEnd });
|
|
421
|
+
setInputValue(nextStart ? formatDateEditable(nextStart) : "");
|
|
422
|
+
setEndInputValue(nextEnd ? formatDateEditable(nextEnd) : "");
|
|
423
|
+
setPreview(null);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Called when range mode is allowed by the caller (range=true) but the user
|
|
428
|
+
* has not toggled "End date" on. Behaves like single-date selection — both
|
|
429
|
+
* start and end are set to the picked date so the caller's filter applies to
|
|
430
|
+
* exactly that day, matching the prior single-date behavior.
|
|
431
|
+
*/
|
|
432
|
+
function selectDateRangeStartOnly(dateStr: string) {
|
|
433
|
+
emitRange({ start: dateStr, end: dateStr });
|
|
434
|
+
setInputValue(formatDateEditable(dateStr));
|
|
435
|
+
setPreview(null);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function selectDate(dateStr: string) {
|
|
439
|
+
if (isRangeActive()) selectDateRange(dateStr);
|
|
440
|
+
else if (isRange()) selectDateRangeStartOnly(dateStr);
|
|
441
|
+
else selectDateSingle(dateStr);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function applyQuickRange(range: DateRangeValue) {
|
|
445
|
+
// If the picked range covers more than one day, auto-enable the End-date
|
|
446
|
+
// toggle so the calendar/inputs reflect the range the user just chose.
|
|
447
|
+
if (range.end && range.end !== range.start) setEndActive(true);
|
|
448
|
+
emitRange(range);
|
|
449
|
+
setInputValue(range.start ? formatDateEditable(range.start) : "");
|
|
450
|
+
setEndInputValue(range.end ? formatDateEditable(range.end) : "");
|
|
451
|
+
setPreview(null);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function clear(e?: MouseEvent) {
|
|
455
|
+
e?.stopPropagation();
|
|
456
|
+
if (isRange()) emitRange({ start: null, end: null });
|
|
457
|
+
else emitSingle(null);
|
|
458
|
+
setTime(undefined);
|
|
459
|
+
setInputValue("");
|
|
460
|
+
setEndInputValue("");
|
|
461
|
+
setPreview(null);
|
|
462
|
+
setHoverDate(null);
|
|
463
|
+
setActiveField("start");
|
|
464
|
+
setOpen(false);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function setViewToAnchor() {
|
|
468
|
+
const anchor = isRange() ? (normalizedStart() ?? normalizedEnd()) : normalizedSingle();
|
|
469
|
+
if (anchor) {
|
|
470
|
+
const d = new Date(anchor + "T00:00:00");
|
|
471
|
+
setViewYear(d.getFullYear());
|
|
472
|
+
setViewMonth(d.getMonth());
|
|
473
|
+
} else {
|
|
474
|
+
setViewYear(today.getFullYear());
|
|
475
|
+
setViewMonth(today.getMonth());
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function seedInputsFromValue() {
|
|
480
|
+
if (isRange()) {
|
|
481
|
+
const r = rangeValue();
|
|
482
|
+
// Re-derive toggle state from current value: a true date span (start≠end)
|
|
483
|
+
// means the user previously turned the toggle on; a single day or empty
|
|
484
|
+
// value means the toggle stays off.
|
|
485
|
+
const span = !!r.start && !!r.end && r.start !== r.end;
|
|
486
|
+
setEndActive(span);
|
|
487
|
+
setInputValue(r.start ? formatDateEditable(r.start) : "");
|
|
488
|
+
setEndInputValue(r.end ? formatDateEditable(r.end) : "");
|
|
489
|
+
setActiveField(r.start && !r.end ? "end" : "start");
|
|
490
|
+
} else {
|
|
491
|
+
const v = singleValue();
|
|
492
|
+
setInputValue(v ? formatDateEditable(v) : "");
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function openPicker() {
|
|
497
|
+
if (props.disabled || open()) return;
|
|
498
|
+
setViewToAnchor();
|
|
499
|
+
seedInputsFromValue();
|
|
500
|
+
setPreview(null);
|
|
501
|
+
setHoverDate(null);
|
|
502
|
+
// Compute the popover's position BEFORE flipping `open` to true. The trigger
|
|
503
|
+
// is already in the DOM, so getBoundingClientRect is valid right now; doing
|
|
504
|
+
// the math here means the popover renders at the right coordinates on its
|
|
505
|
+
// very first paint instead of briefly flashing at top:0/left:0 and then
|
|
506
|
+
// jumping into place inside the next animation frame.
|
|
507
|
+
if (triggerRef) {
|
|
508
|
+
const rect = triggerRef.getBoundingClientRect();
|
|
509
|
+
const vw = window.innerWidth;
|
|
510
|
+
const width = popoverPxWidth();
|
|
511
|
+
setPopoverPos({
|
|
512
|
+
top: rect.bottom + 8,
|
|
513
|
+
left: Math.max(8, Math.min(rect.left, vw - (width + 10))),
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
setOpen(true);
|
|
517
|
+
// Focus still has to wait one frame for the input element to be in the DOM.
|
|
518
|
+
requestAnimationFrame(() => {
|
|
519
|
+
inputRef?.focus();
|
|
520
|
+
inputRef?.select();
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ── Text input handling ────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
function handleInput(e: InputEvent, field: "start" | "end") {
|
|
527
|
+
const val = (e.currentTarget as HTMLInputElement).value;
|
|
528
|
+
if (field === "start") setInputValue(val);
|
|
529
|
+
else setEndInputValue(val);
|
|
530
|
+
setActiveField(field);
|
|
531
|
+
|
|
532
|
+
const parsed = parseDateInput(val);
|
|
533
|
+
setPreview(parsed);
|
|
534
|
+
|
|
535
|
+
if (parsed) {
|
|
536
|
+
const d = new Date(parsed.date + "T00:00:00");
|
|
537
|
+
setViewYear(d.getFullYear());
|
|
538
|
+
setViewMonth(d.getMonth());
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
543
|
+
if (e.key === "Enter") {
|
|
544
|
+
const p = preview();
|
|
545
|
+
if (p) {
|
|
546
|
+
if (p.time) setTime(p.time);
|
|
547
|
+
selectDate(p.date);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (e.key === "Escape") {
|
|
551
|
+
setOpen(false);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ── Time input ─────────────────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
const [timeInput, setTimeInput] = createSignal("");
|
|
558
|
+
const [timeValid, setTimeValid] = createSignal(true);
|
|
559
|
+
|
|
560
|
+
function handleTimeInput(e: InputEvent) {
|
|
561
|
+
const val = (e.currentTarget as HTMLInputElement).value;
|
|
562
|
+
setTimeInput(val);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function handleTimeBlur() {
|
|
566
|
+
const val = timeInput().trim();
|
|
567
|
+
if (!val) {
|
|
568
|
+
setTime(undefined);
|
|
569
|
+
setTimeValid(true);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const parsed = parseDateInput(`today ${val}`);
|
|
573
|
+
if (parsed?.time) {
|
|
574
|
+
setTime(parsed.time);
|
|
575
|
+
setTimeValid(true);
|
|
576
|
+
setTimeInput(formatTimeDisplay(parsed.time));
|
|
577
|
+
} else {
|
|
578
|
+
setTimeValid(false);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function handleTimeKeyDown(e: KeyboardEvent) {
|
|
583
|
+
if (e.key === "Enter") {
|
|
584
|
+
(e.currentTarget as HTMLInputElement).blur();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
createEffect(
|
|
589
|
+
on(time, (t) => {
|
|
590
|
+
if (t) setTimeInput(formatTimeDisplay(t));
|
|
591
|
+
}),
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
// ── Click outside ──────────────────────────────────────────────────────────
|
|
595
|
+
|
|
596
|
+
function handleClickOutside(e: MouseEvent) {
|
|
597
|
+
if (
|
|
598
|
+
popoverRef &&
|
|
599
|
+
!popoverRef.contains(e.target as Node) &&
|
|
600
|
+
triggerRef &&
|
|
601
|
+
!triggerRef.contains(e.target as Node) &&
|
|
602
|
+
(!popoverPanelRef || !popoverPanelRef.contains(e.target as Node))
|
|
603
|
+
) {
|
|
604
|
+
setOpen(false);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
onMount(() => {
|
|
609
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
610
|
+
onCleanup(() => document.removeEventListener("mousedown", handleClickOutside));
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// ── Reposition on scroll / resize ──────────────────────────────────────────
|
|
614
|
+
function reposition() {
|
|
615
|
+
if (!triggerRef) return;
|
|
616
|
+
const rect = triggerRef.getBoundingClientRect();
|
|
617
|
+
const vw = window.innerWidth;
|
|
618
|
+
const width = popoverPxWidth();
|
|
619
|
+
setPopoverPos({
|
|
620
|
+
top: rect.bottom + 8,
|
|
621
|
+
left: Math.max(8, Math.min(rect.left, vw - (width + 10))),
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
createEffect(() => {
|
|
626
|
+
if (!open()) return;
|
|
627
|
+
window.addEventListener("scroll", reposition, { passive: true, capture: true });
|
|
628
|
+
window.addEventListener("resize", reposition);
|
|
629
|
+
onCleanup(() => {
|
|
630
|
+
window.removeEventListener("scroll", reposition, { capture: true } as EventListenerOptions);
|
|
631
|
+
window.removeEventListener("resize", reposition);
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// ── Display value ──────────────────────────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
const displayValue = createMemo(() => {
|
|
638
|
+
if (isRange()) {
|
|
639
|
+
const r = rangeValue();
|
|
640
|
+
if (!r.start && !r.end) return null;
|
|
641
|
+
// Only one bound set ⇒ show just that date (no arrow). Both bounds set
|
|
642
|
+
// and equal ⇒ single date. Otherwise ⇒ range.
|
|
643
|
+
if (r.start && !r.end) return formatDateDisplay(r.start);
|
|
644
|
+
if (!r.start && r.end) return formatDateDisplay(r.end);
|
|
645
|
+
const s = formatDateDisplay(r.start!);
|
|
646
|
+
const e = formatDateDisplay(r.end!);
|
|
647
|
+
return r.start === r.end ? s : `${s} → ${e}`;
|
|
648
|
+
}
|
|
649
|
+
const v = singleValue();
|
|
650
|
+
if (!v) return null;
|
|
651
|
+
let text = formatDateDisplay(v);
|
|
652
|
+
const t = time();
|
|
653
|
+
if (t) text += ` ${formatTimeDisplay(t)}`;
|
|
654
|
+
return text;
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
const hasValue = (): boolean => {
|
|
658
|
+
if (isRange()) {
|
|
659
|
+
const r = rangeValue();
|
|
660
|
+
return !!(r.start || r.end);
|
|
661
|
+
}
|
|
662
|
+
return !!singleValue();
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
// ── Cell-state classifier (range mode) ─────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
function hoverPreviewState(dateStr: string, start: string): "preview" | null {
|
|
668
|
+
const hov = hoverDate();
|
|
669
|
+
if (!(hov && activeField() === "end")) return null;
|
|
670
|
+
const lo = start < hov ? start : hov;
|
|
671
|
+
const hi = start < hov ? hov : start;
|
|
672
|
+
if (dateStr > lo && dateStr < hi) return "preview";
|
|
673
|
+
if (dateStr === hov && hov !== start) return "preview";
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function rangeCellState(dateStr: string): "start" | "end" | "in-range" | "preview" | null {
|
|
678
|
+
if (!isRangeActive()) return null;
|
|
679
|
+
const r = rangeValue();
|
|
680
|
+
const start = r.start ? normalizeDate(r.start) : null;
|
|
681
|
+
const end = r.end ? normalizeDate(r.end) : null;
|
|
682
|
+
|
|
683
|
+
if (start && dateStr === start) return "start";
|
|
684
|
+
if (end && dateStr === end) return "end";
|
|
685
|
+
if (start && end && dateStr > start && dateStr < end) return "in-range";
|
|
686
|
+
|
|
687
|
+
if (start && !end) return hoverPreviewState(dateStr, start);
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ── Render ─────────────────────────────────────────────────────────────────
|
|
692
|
+
|
|
693
|
+
const popoverWidth = () =>
|
|
694
|
+
isRangeActive() ? "min(22.5rem,calc(100vw-2rem))" : "min(20rem,calc(100vw-2rem))";
|
|
695
|
+
|
|
696
|
+
return (
|
|
697
|
+
<div class="ksui-datepicker" ref={popoverRef}>
|
|
698
|
+
{/* Trigger button */}
|
|
699
|
+
<button
|
|
700
|
+
ref={triggerRef}
|
|
701
|
+
onClick={openPicker}
|
|
702
|
+
type="button"
|
|
703
|
+
tabIndex={-1}
|
|
704
|
+
disabled={props.disabled}
|
|
705
|
+
class={
|
|
706
|
+
props.triggerClass ??
|
|
707
|
+
`ksui-datepicker-trigger ${hasValue() ? "ksui-datepicker-trigger-active" : ""}`
|
|
708
|
+
}
|
|
709
|
+
>
|
|
710
|
+
<Calendar size={14} />
|
|
711
|
+
<span>{displayValue() ?? props.placeholder ?? "Pick date"}</span>
|
|
712
|
+
</button>
|
|
713
|
+
{/* Clear button */}
|
|
714
|
+
<Show when={hasValue()}>
|
|
715
|
+
<button type="button" tabIndex={-1} class="ksui-datepicker-clear-btn" onClick={clear}>
|
|
716
|
+
<X size={14} />
|
|
717
|
+
</button>
|
|
718
|
+
</Show>
|
|
719
|
+
|
|
720
|
+
{/* Popover — portaled to document.body so ancestor clip-path / overflow
|
|
721
|
+
(sheet modals use clip-path corners) doesn't clip the calendar. */}
|
|
722
|
+
<Show when={open()}>
|
|
723
|
+
<Portal>
|
|
724
|
+
<div
|
|
725
|
+
ref={popoverPanelRef}
|
|
726
|
+
data-testid="datepicker-popover"
|
|
727
|
+
class="ksui-datepicker-popover"
|
|
728
|
+
style={{
|
|
729
|
+
top: `${popoverPos().top}px`,
|
|
730
|
+
left: `${popoverPos().left}px`,
|
|
731
|
+
width: popoverWidth(),
|
|
732
|
+
// Keep the popover inside the viewport on short screens (mobile,
|
|
733
|
+
// or when the trigger sits near the bottom). Without this, the
|
|
734
|
+
// calendar's lower rows fall off the bottom of the screen with
|
|
735
|
+
// no scroll path — `position: fixed` ignores page scroll.
|
|
736
|
+
"max-height": `calc(100vh - ${popoverPos().top}px - 8px)`,
|
|
737
|
+
}}
|
|
738
|
+
>
|
|
739
|
+
{/* Text input(s) */}
|
|
740
|
+
<div class="ksui-datepicker-section-bordered">
|
|
741
|
+
<Show when={!isRangeActive()}>
|
|
742
|
+
<input
|
|
743
|
+
ref={inputRef}
|
|
744
|
+
type="text"
|
|
745
|
+
value={inputValue()}
|
|
746
|
+
onInput={(e) => handleInput(e, "start")}
|
|
747
|
+
onKeyDown={handleKeyDown}
|
|
748
|
+
placeholder="Type a date... dec 3, yesterday, last week"
|
|
749
|
+
class="ksui-datepicker-input"
|
|
750
|
+
/>
|
|
751
|
+
</Show>
|
|
752
|
+
<Show when={isRangeActive()}>
|
|
753
|
+
<div class="ksui-datepicker-range-row">
|
|
754
|
+
<input
|
|
755
|
+
ref={inputRef}
|
|
756
|
+
type="text"
|
|
757
|
+
value={inputValue()}
|
|
758
|
+
onInput={(e) => handleInput(e, "start")}
|
|
759
|
+
onFocus={() => setActiveField("start")}
|
|
760
|
+
onKeyDown={handleKeyDown}
|
|
761
|
+
placeholder="Start"
|
|
762
|
+
data-testid="datepicker-range-start"
|
|
763
|
+
class={`ksui-datepicker-input ksui-datepicker-range-input ${
|
|
764
|
+
activeField() === "start" ? "ksui-datepicker-range-input-active" : ""
|
|
765
|
+
}`}
|
|
766
|
+
/>
|
|
767
|
+
<span class="ksui-datepicker-range-sep">to</span>
|
|
768
|
+
<input
|
|
769
|
+
ref={endInputRef}
|
|
770
|
+
type="text"
|
|
771
|
+
value={endInputValue()}
|
|
772
|
+
onInput={(e) => handleInput(e, "end")}
|
|
773
|
+
onFocus={() => setActiveField("end")}
|
|
774
|
+
onKeyDown={handleKeyDown}
|
|
775
|
+
placeholder="End"
|
|
776
|
+
data-testid="datepicker-range-end"
|
|
777
|
+
class={`ksui-datepicker-input ksui-datepicker-range-input ${
|
|
778
|
+
activeField() === "end" ? "ksui-datepicker-range-input-active" : ""
|
|
779
|
+
}`}
|
|
780
|
+
/>
|
|
781
|
+
</div>
|
|
782
|
+
</Show>
|
|
783
|
+
<Show when={preview()}>
|
|
784
|
+
<div class="ksui-datepicker-preview">
|
|
785
|
+
<span class="ksui-datepicker-preview-chip">
|
|
786
|
+
{formatDateDisplay(preview()!.date)}
|
|
787
|
+
{preview()!.time && ` at ${formatTimeDisplay(preview()!.time!)}`}
|
|
788
|
+
</span>
|
|
789
|
+
<span class="ksui-datepicker-preview-hint">Press Enter to select</span>
|
|
790
|
+
</div>
|
|
791
|
+
</Show>
|
|
792
|
+
</div>
|
|
793
|
+
|
|
794
|
+
{/* Quick options. In range mode the picker emits a range shape regardless
|
|
795
|
+
of toggle state, but the labels still match what the user sees: a single
|
|
796
|
+
date when the End-date toggle is off, a span when it's on. */}
|
|
797
|
+
<div class="ksui-datepicker-quick">
|
|
798
|
+
<Show when={!isRange()}>
|
|
799
|
+
<For each={QUICK_OPTIONS_SINGLE}>
|
|
800
|
+
{(opt) => (
|
|
801
|
+
<button
|
|
802
|
+
type="button"
|
|
803
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
804
|
+
onClick={() => selectDate(opt.getDate())}
|
|
805
|
+
class={`ksui-datepicker-quick-btn ${
|
|
806
|
+
normalizedSingle() === opt.getDate() ? "ksui-datepicker-quick-btn-active" : ""
|
|
807
|
+
}`}
|
|
808
|
+
>
|
|
809
|
+
{opt.label}
|
|
810
|
+
</button>
|
|
811
|
+
)}
|
|
812
|
+
</For>
|
|
813
|
+
</Show>
|
|
814
|
+
<Show when={isRange()}>
|
|
815
|
+
<For each={QUICK_OPTIONS_RANGE}>
|
|
816
|
+
{(opt) => {
|
|
817
|
+
const isActive = () => {
|
|
818
|
+
const r = rangeValue();
|
|
819
|
+
const target = opt.getRange();
|
|
820
|
+
return r.start === target.start && r.end === target.end;
|
|
821
|
+
};
|
|
822
|
+
return (
|
|
823
|
+
<button
|
|
824
|
+
type="button"
|
|
825
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
826
|
+
onClick={() => applyQuickRange(opt.getRange())}
|
|
827
|
+
class={`ksui-datepicker-quick-btn ${
|
|
828
|
+
isActive() ? "ksui-datepicker-quick-btn-active" : ""
|
|
829
|
+
}`}
|
|
830
|
+
>
|
|
831
|
+
{opt.label}
|
|
832
|
+
</button>
|
|
833
|
+
);
|
|
834
|
+
}}
|
|
835
|
+
</For>
|
|
836
|
+
</Show>
|
|
837
|
+
</div>
|
|
838
|
+
|
|
839
|
+
{/* Calendar */}
|
|
840
|
+
<div class="ksui-datepicker-section">
|
|
841
|
+
{/* Month nav */}
|
|
842
|
+
<div class="ksui-datepicker-nav">
|
|
843
|
+
<button type="button" onClick={prevMonth} class="ksui-datepicker-nav-btn">
|
|
844
|
+
<ChevronLeft size={16} />
|
|
845
|
+
</button>
|
|
846
|
+
<span class="ksui-datepicker-month-label">{monthLabel()}</span>
|
|
847
|
+
<button type="button" onClick={nextMonth} class="ksui-datepicker-nav-btn">
|
|
848
|
+
<ChevronRight size={16} />
|
|
849
|
+
</button>
|
|
850
|
+
</div>
|
|
851
|
+
|
|
852
|
+
{/* Day headers */}
|
|
853
|
+
<div class="ksui-datepicker-dow">
|
|
854
|
+
<For each={DAYS}>{(d) => <span>{d}</span>}</For>
|
|
855
|
+
</div>
|
|
856
|
+
|
|
857
|
+
{/* Day cells */}
|
|
858
|
+
<div class="ksui-datepicker-grid">
|
|
859
|
+
<For each={calendarDays()}>
|
|
860
|
+
{(cell) => {
|
|
861
|
+
// Single-style "selected" covers both the no-range path
|
|
862
|
+
// (highlight `value`) and the range-but-toggle-off path
|
|
863
|
+
// (highlight `value.start`, since end isn't in play yet).
|
|
864
|
+
const isSelectedSingle = () => {
|
|
865
|
+
if (isRangeActive()) return false;
|
|
866
|
+
if (!isRange()) return normalizedSingle() === cell.dateStr;
|
|
867
|
+
return normalizedStart() === cell.dateStr;
|
|
868
|
+
};
|
|
869
|
+
const isPreviewSingle = () =>
|
|
870
|
+
!isRangeActive() && preview()?.date === cell.dateStr && !isSelectedSingle();
|
|
871
|
+
const isToday = () => cell.dateStr === todayStrConst;
|
|
872
|
+
const rangeState = createMemo(() => rangeCellState(cell.dateStr));
|
|
873
|
+
const cellStateClass = (): string => {
|
|
874
|
+
if (!cell.current) return "ksui-datepicker-cell-out";
|
|
875
|
+
const rs = rangeState();
|
|
876
|
+
if (rs === "start" || rs === "end") return "ksui-datepicker-cell-range-end";
|
|
877
|
+
if (rs === "in-range") return "ksui-datepicker-cell-in-range";
|
|
878
|
+
if (rs === "preview") return "ksui-datepicker-cell-range-preview";
|
|
879
|
+
if (isSelectedSingle()) return "ksui-datepicker-cell-selected";
|
|
880
|
+
if (isPreviewSingle()) return "ksui-datepicker-cell-preview";
|
|
881
|
+
if (isToday()) return "ksui-datepicker-cell-today";
|
|
882
|
+
return "";
|
|
883
|
+
};
|
|
884
|
+
return (
|
|
885
|
+
<button
|
|
886
|
+
type="button"
|
|
887
|
+
// Keep focus on whichever text input was active so the
|
|
888
|
+
// user can keep typing after clicking a date. Without
|
|
889
|
+
// this, the click moves focus to the day button and the
|
|
890
|
+
// input loses its caret.
|
|
891
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
892
|
+
onMouseEnter={() => isRangeActive() && setHoverDate(cell.dateStr)}
|
|
893
|
+
onMouseLeave={() => isRangeActive() && setHoverDate(null)}
|
|
894
|
+
class={`ksui-datepicker-cell ${cellStateClass()}`}
|
|
895
|
+
onClick={() => selectDate(cell.dateStr)}
|
|
896
|
+
>
|
|
897
|
+
{cell.day}
|
|
898
|
+
</button>
|
|
899
|
+
);
|
|
900
|
+
}}
|
|
901
|
+
</For>
|
|
902
|
+
</div>
|
|
903
|
+
</div>
|
|
904
|
+
|
|
905
|
+
{/* End-date toggle (only when caller has opted in via range=true). */}
|
|
906
|
+
<Show when={isRange()}>
|
|
907
|
+
<div class="ksui-datepicker-toggle-row">
|
|
908
|
+
<span>End date</span>
|
|
909
|
+
<button
|
|
910
|
+
type="button"
|
|
911
|
+
role="switch"
|
|
912
|
+
aria-label="End date"
|
|
913
|
+
aria-checked={endActive()}
|
|
914
|
+
data-testid="datepicker-end-date-toggle"
|
|
915
|
+
onClick={() => {
|
|
916
|
+
const next = !endActive();
|
|
917
|
+
setEndActive(next);
|
|
918
|
+
if (!next) {
|
|
919
|
+
// Toggling off: collapse end onto start so the filter is a
|
|
920
|
+
// single day. If start is empty, clear end too.
|
|
921
|
+
const r = rangeValue();
|
|
922
|
+
emitRange({ start: r.start, end: r.start });
|
|
923
|
+
setEndInputValue(r.start ? formatDateEditable(r.start) : "");
|
|
924
|
+
} else {
|
|
925
|
+
// Toggling on: focus the end input so the user can pick.
|
|
926
|
+
setActiveField("end");
|
|
927
|
+
requestAnimationFrame(() => endInputRef?.focus());
|
|
928
|
+
}
|
|
929
|
+
}}
|
|
930
|
+
class={`ksui-datepicker-switch ${endActive() ? "ksui-datepicker-switch-on" : ""}`}
|
|
931
|
+
>
|
|
932
|
+
<span class="ksui-datepicker-switch-knob" />
|
|
933
|
+
</button>
|
|
934
|
+
</div>
|
|
935
|
+
</Show>
|
|
936
|
+
|
|
937
|
+
{/* Time input (single mode only) */}
|
|
938
|
+
<Show when={props.withTime && !isRange()}>
|
|
939
|
+
<div class="ksui-datepicker-time-row">
|
|
940
|
+
<div class="ksui-datepicker-time-inner">
|
|
941
|
+
<span class="ksui-datepicker-time-icon">
|
|
942
|
+
<Clock size={14} />
|
|
943
|
+
</span>
|
|
944
|
+
<input
|
|
945
|
+
type="text"
|
|
946
|
+
value={timeInput()}
|
|
947
|
+
onInput={handleTimeInput}
|
|
948
|
+
onBlur={handleTimeBlur}
|
|
949
|
+
onKeyDown={handleTimeKeyDown}
|
|
950
|
+
placeholder="7pm, 7:00pm, 19:00"
|
|
951
|
+
class={`ksui-datepicker-time-input ${
|
|
952
|
+
timeValid() ? "" : "ksui-datepicker-time-input-invalid"
|
|
953
|
+
}`}
|
|
954
|
+
/>
|
|
955
|
+
<Show when={time()}>
|
|
956
|
+
<button
|
|
957
|
+
type="button"
|
|
958
|
+
onClick={() => {
|
|
959
|
+
setTime(undefined);
|
|
960
|
+
setTimeInput("");
|
|
961
|
+
setTimeValid(true);
|
|
962
|
+
}}
|
|
963
|
+
class="ksui-datepicker-time-clear"
|
|
964
|
+
>
|
|
965
|
+
<X size={12} />
|
|
966
|
+
</button>
|
|
967
|
+
</Show>
|
|
968
|
+
</div>
|
|
969
|
+
<Show when={!timeValid()}>
|
|
970
|
+
<p class="ksui-datepicker-time-error">Try: 5pm, 5:30pm, or 17:00</p>
|
|
971
|
+
</Show>
|
|
972
|
+
</div>
|
|
973
|
+
</Show>
|
|
974
|
+
|
|
975
|
+
{/* Footer — only shown when there is a value to clear. The "Today"
|
|
976
|
+
shortcut already lives in the quick-options strip above, so a
|
|
977
|
+
duplicate here would be redundant. */}
|
|
978
|
+
<Show when={hasValue()}>
|
|
979
|
+
<div class="ksui-datepicker-footer">
|
|
980
|
+
<button type="button" class="ksui-datepicker-footer-clear" onClick={() => clear()}>
|
|
981
|
+
Clear
|
|
982
|
+
</button>
|
|
983
|
+
</div>
|
|
984
|
+
</Show>
|
|
985
|
+
</div>
|
|
986
|
+
</Portal>
|
|
987
|
+
</Show>
|
|
988
|
+
</div>
|
|
989
|
+
);
|
|
990
|
+
}
|