@timeax/form-palette 0.0.1
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/.scaffold-cache.json +537 -0
- package/package.json +42 -0
- package/src/.scaffold-cache.json +544 -0
- package/src/adapters/axios.ts +117 -0
- package/src/adapters/index.ts +91 -0
- package/src/adapters/inertia.ts +187 -0
- package/src/core/adapter-registry.ts +87 -0
- package/src/core/bound/bind-host.ts +14 -0
- package/src/core/bound/observe-bound-field.ts +172 -0
- package/src/core/bound/wait-for-bound-field.ts +57 -0
- package/src/core/context.ts +23 -0
- package/src/core/core-provider.tsx +818 -0
- package/src/core/core-root.tsx +72 -0
- package/src/core/core-shell.tsx +44 -0
- package/src/core/errors/error-strip.tsx +71 -0
- package/src/core/errors/index.ts +2 -0
- package/src/core/errors/map-error-bag.ts +51 -0
- package/src/core/errors/map-zod.ts +39 -0
- package/src/core/hooks/use-button.ts +220 -0
- package/src/core/hooks/use-core-context.ts +20 -0
- package/src/core/hooks/use-core-utility.ts +0 -0
- package/src/core/hooks/use-core.ts +13 -0
- package/src/core/hooks/use-field.ts +497 -0
- package/src/core/hooks/use-optional-field.ts +28 -0
- package/src/core/index.ts +0 -0
- package/src/core/registry/binder-registry.ts +82 -0
- package/src/core/registry/field-registry.ts +187 -0
- package/src/core/test.tsx +17 -0
- package/src/global.d.ts +14 -0
- package/src/index.ts +68 -0
- package/src/input/index.ts +4 -0
- package/src/input/input-field.tsx +854 -0
- package/src/input/input-layout-graph.ts +230 -0
- package/src/input/input-props.ts +190 -0
- package/src/lib/get-global-countries.ts +87 -0
- package/src/lib/utils.ts +6 -0
- package/src/presets/index.ts +0 -0
- package/src/presets/shadcn-preset.ts +0 -0
- package/src/presets/shadcn-variants/checkbox.tsx +849 -0
- package/src/presets/shadcn-variants/chips.tsx +756 -0
- package/src/presets/shadcn-variants/color.tsx +284 -0
- package/src/presets/shadcn-variants/custom.tsx +227 -0
- package/src/presets/shadcn-variants/date.tsx +796 -0
- package/src/presets/shadcn-variants/file.tsx +764 -0
- package/src/presets/shadcn-variants/keyvalue.tsx +556 -0
- package/src/presets/shadcn-variants/multiselect.tsx +1132 -0
- package/src/presets/shadcn-variants/number.tsx +176 -0
- package/src/presets/shadcn-variants/password.tsx +737 -0
- package/src/presets/shadcn-variants/phone.tsx +628 -0
- package/src/presets/shadcn-variants/radio.tsx +578 -0
- package/src/presets/shadcn-variants/select.tsx +956 -0
- package/src/presets/shadcn-variants/slider.tsx +622 -0
- package/src/presets/shadcn-variants/text.tsx +343 -0
- package/src/presets/shadcn-variants/textarea.tsx +66 -0
- package/src/presets/shadcn-variants/toggle.tsx +218 -0
- package/src/presets/shadcn-variants/treeselect.tsx +784 -0
- package/src/presets/ui/badge.tsx +46 -0
- package/src/presets/ui/button.tsx +60 -0
- package/src/presets/ui/calendar.tsx +214 -0
- package/src/presets/ui/checkbox.tsx +115 -0
- package/src/presets/ui/custom.tsx +0 -0
- package/src/presets/ui/dialog.tsx +141 -0
- package/src/presets/ui/field.tsx +246 -0
- package/src/presets/ui/input-mask.tsx +739 -0
- package/src/presets/ui/input-otp.tsx +77 -0
- package/src/presets/ui/input.tsx +1011 -0
- package/src/presets/ui/label.tsx +22 -0
- package/src/presets/ui/number.tsx +1370 -0
- package/src/presets/ui/popover.tsx +46 -0
- package/src/presets/ui/radio-group.tsx +43 -0
- package/src/presets/ui/scroll-area.tsx +56 -0
- package/src/presets/ui/select.tsx +190 -0
- package/src/presets/ui/separator.tsx +28 -0
- package/src/presets/ui/slider.tsx +61 -0
- package/src/presets/ui/switch.tsx +32 -0
- package/src/presets/ui/textarea.tsx +634 -0
- package/src/presets/ui/time-dropdowns.tsx +350 -0
- package/src/schema/adapter.ts +217 -0
- package/src/schema/core.ts +429 -0
- package/src/schema/field-map.ts +0 -0
- package/src/schema/field.ts +224 -0
- package/src/schema/index.ts +0 -0
- package/src/schema/input-field.ts +260 -0
- package/src/schema/presets.ts +0 -0
- package/src/schema/variant.ts +216 -0
- package/src/variants/core/checkbox.tsx +54 -0
- package/src/variants/core/chips.tsx +22 -0
- package/src/variants/core/color.tsx +16 -0
- package/src/variants/core/custom.tsx +18 -0
- package/src/variants/core/date.tsx +25 -0
- package/src/variants/core/file.tsx +9 -0
- package/src/variants/core/keyvalue.tsx +12 -0
- package/src/variants/core/multiselect.tsx +28 -0
- package/src/variants/core/number.tsx +115 -0
- package/src/variants/core/password.tsx +35 -0
- package/src/variants/core/phone.tsx +16 -0
- package/src/variants/core/radio.tsx +38 -0
- package/src/variants/core/select.tsx +15 -0
- package/src/variants/core/slider.tsx +55 -0
- package/src/variants/core/text.tsx +114 -0
- package/src/variants/core/textarea.tsx +22 -0
- package/src/variants/core/toggle.tsx +50 -0
- package/src/variants/core/treeselect.tsx +11 -0
- package/src/variants/helpers/selection-summary.tsx +236 -0
- package/src/variants/index.ts +75 -0
- package/src/variants/registry.ts +38 -0
- package/src/variants/select-shared.ts +0 -0
- package/src/variants/shared.ts +126 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Calendar as CalendarIcon, X as XIcon } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import type { VariantBaseProps, ChangeDetail } from "@/variants/shared";
|
|
5
|
+
import type { ShadcnTextVariantProps } from "@/presets/shadcn-variants/text";
|
|
6
|
+
import { Input } from "@/presets/ui/input";
|
|
7
|
+
import { Popover, PopoverTrigger, PopoverContent } from "@/presets/ui/popover";
|
|
8
|
+
import { Calendar } from "@/presets/ui/calendar";
|
|
9
|
+
import { TimeDropdowns } from "../ui/time-dropdowns";
|
|
10
|
+
|
|
11
|
+
type DateMode = "single" | "range";
|
|
12
|
+
|
|
13
|
+
export interface DateRange {
|
|
14
|
+
from?: Date;
|
|
15
|
+
to?: Date;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type DateValue = Date | DateRange | undefined;
|
|
19
|
+
|
|
20
|
+
type BaseProps = VariantBaseProps<DateValue>;
|
|
21
|
+
|
|
22
|
+
// Calendar disabled type from your calendar wrapper
|
|
23
|
+
type DisabledDays = React.ComponentProps<typeof Calendar>["disabled"];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Logical temporal "kind" for the field.
|
|
27
|
+
*
|
|
28
|
+
* This controls the default mask + formatting/parsing.
|
|
29
|
+
*
|
|
30
|
+
* - "date" → yyyy-MM-dd (default)
|
|
31
|
+
* - "datetime" → yyyy-MM-dd HH:mm
|
|
32
|
+
* - "time" → HH:mm
|
|
33
|
+
* - "hour" → HH
|
|
34
|
+
* - "monthYear" → MM/yyyy
|
|
35
|
+
* - "year" → yyyy
|
|
36
|
+
*/
|
|
37
|
+
export type DateKind =
|
|
38
|
+
| "date"
|
|
39
|
+
| "datetime"
|
|
40
|
+
| "time"
|
|
41
|
+
| "hour"
|
|
42
|
+
| "monthYear"
|
|
43
|
+
| "year"
|
|
44
|
+
| (string & {});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Public props for the date variant (legacy + mask extensions).
|
|
48
|
+
*/
|
|
49
|
+
export interface DateVariantProps {
|
|
50
|
+
mode?: DateMode;
|
|
51
|
+
placeholder?: React.ReactNode;
|
|
52
|
+
|
|
53
|
+
clearable?: boolean;
|
|
54
|
+
|
|
55
|
+
minDate?: Date;
|
|
56
|
+
maxDate?: Date;
|
|
57
|
+
disabledDays?: DisabledDays;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pattern for single dates.
|
|
61
|
+
*
|
|
62
|
+
* Tokens:
|
|
63
|
+
* - yyyy → full year
|
|
64
|
+
* - MM → month (01–12)
|
|
65
|
+
* - dd → day (01–31)
|
|
66
|
+
* - HH → hours (00–23)
|
|
67
|
+
* - mm → minutes (00–59)
|
|
68
|
+
*
|
|
69
|
+
* Default depends on `kind`:
|
|
70
|
+
* - date → "yyyy-MM-dd"
|
|
71
|
+
* - datetime → "yyyy-MM-dd HH:mm"
|
|
72
|
+
* - time → "HH:mm"
|
|
73
|
+
* - hour → "HH"
|
|
74
|
+
* - monthYear → "MM/yyyy"
|
|
75
|
+
* - year → "yyyy"
|
|
76
|
+
*/
|
|
77
|
+
formatSingle?: string;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* String pattern or custom formatter for ranges.
|
|
81
|
+
*
|
|
82
|
+
* - string → same token rules as formatSingle, applied to both ends
|
|
83
|
+
* - function → full control over display text
|
|
84
|
+
*/
|
|
85
|
+
formatRange?:
|
|
86
|
+
| string
|
|
87
|
+
| ((range: DateRange | undefined) => string);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Separator when formatRange is a string pattern.
|
|
91
|
+
* Default: " – "
|
|
92
|
+
*/
|
|
93
|
+
rangeSeparator?: string;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* When true, keep the calendar open after a selection.
|
|
97
|
+
*
|
|
98
|
+
* For range mode, the picker also stays open until both
|
|
99
|
+
* `from` and `to` are chosen.
|
|
100
|
+
*/
|
|
101
|
+
stayOpenOnSelect?: boolean;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Controlled open state for the popover.
|
|
105
|
+
*/
|
|
106
|
+
open?: boolean;
|
|
107
|
+
onOpenChange?(o: boolean): void;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Temporal kind (controls default mask + formatting/parsing).
|
|
111
|
+
*
|
|
112
|
+
* Default: "date".
|
|
113
|
+
*/
|
|
114
|
+
kind?: DateKind;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Optional explicit input mask pattern for the text input.
|
|
118
|
+
*
|
|
119
|
+
* If omitted, a sensible default based on `kind` is used.
|
|
120
|
+
*
|
|
121
|
+
* Mask tokens follow the same rules as the underlying Input mask:
|
|
122
|
+
* 9 = digit, a = letter, * = alphanumeric.
|
|
123
|
+
*/
|
|
124
|
+
inputMask?: string;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether to render the calendar popover.
|
|
128
|
+
*
|
|
129
|
+
* Defaults:
|
|
130
|
+
* - true for `kind` = "date" | "datetime"
|
|
131
|
+
* - false for time-only kinds ("time", "hour", "monthYear", "year")
|
|
132
|
+
*/
|
|
133
|
+
showCalendar?: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* We still reuse the Shadcn text UI props (size, density, icons, etc.),
|
|
138
|
+
* but we take over type/value/onValue and the controls.
|
|
139
|
+
*/
|
|
140
|
+
type TextUiProps = Omit<
|
|
141
|
+
ShadcnTextVariantProps,
|
|
142
|
+
| "type"
|
|
143
|
+
| "inputMode"
|
|
144
|
+
| "leadingControl"
|
|
145
|
+
| "trailingControl"
|
|
146
|
+
| "value"
|
|
147
|
+
| "onValue"
|
|
148
|
+
>;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Full props for the Shadcn-based date variant.
|
|
152
|
+
*/
|
|
153
|
+
export type ShadcnDateVariantProps = TextUiProps &
|
|
154
|
+
DateVariantProps &
|
|
155
|
+
Pick<BaseProps, "value" | "onValue" | "error">;
|
|
156
|
+
|
|
157
|
+
// ─────────────────────────────────────────────
|
|
158
|
+
// Helpers
|
|
159
|
+
// ─────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function isRange(value: DateValue): value is DateRange {
|
|
162
|
+
return !!value && !(value instanceof Date);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizeValueForMode(
|
|
166
|
+
value: DateValue,
|
|
167
|
+
mode: DateMode,
|
|
168
|
+
): { single: Date | undefined; range: DateRange | undefined } {
|
|
169
|
+
if (mode === "single") {
|
|
170
|
+
if (value instanceof Date) {
|
|
171
|
+
return { single: value, range: undefined };
|
|
172
|
+
}
|
|
173
|
+
if (isRange(value)) {
|
|
174
|
+
// prefer "from" when coming from a range
|
|
175
|
+
return { single: value.from ?? value.to, range: undefined };
|
|
176
|
+
}
|
|
177
|
+
return { single: undefined, range: undefined };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// range mode
|
|
181
|
+
if (isRange(value)) {
|
|
182
|
+
return { single: undefined, range: value };
|
|
183
|
+
}
|
|
184
|
+
if (value instanceof Date) {
|
|
185
|
+
return { single: undefined, range: { from: value } };
|
|
186
|
+
}
|
|
187
|
+
return { single: undefined, range: undefined };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function hasSelection(value: DateValue): boolean {
|
|
191
|
+
if (!value) return false;
|
|
192
|
+
if (value instanceof Date) return true;
|
|
193
|
+
return !!value.from || !!value.to;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isRangeComplete(range: DateRange | undefined): boolean {
|
|
197
|
+
return !!(range && range.from && range.to);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function pad2(n: number): string {
|
|
201
|
+
return n.toString().padStart(2, "0");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface KindConfig {
|
|
205
|
+
mask: string;
|
|
206
|
+
singlePattern: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resolveKindConfig(kind: DateKind | undefined): KindConfig {
|
|
210
|
+
const k = (kind ?? "date") as DateKind;
|
|
211
|
+
|
|
212
|
+
switch (k) {
|
|
213
|
+
case "datetime":
|
|
214
|
+
return {
|
|
215
|
+
mask: "9999-99-99 99:99",
|
|
216
|
+
singlePattern: "yyyy-MM-dd HH:mm",
|
|
217
|
+
};
|
|
218
|
+
case "time":
|
|
219
|
+
return {
|
|
220
|
+
mask: "99:99",
|
|
221
|
+
singlePattern: "HH:mm",
|
|
222
|
+
};
|
|
223
|
+
case "hour":
|
|
224
|
+
return {
|
|
225
|
+
mask: "99",
|
|
226
|
+
singlePattern: "HH",
|
|
227
|
+
};
|
|
228
|
+
case "monthYear":
|
|
229
|
+
return {
|
|
230
|
+
mask: "99/9999",
|
|
231
|
+
singlePattern: "MM/yyyy",
|
|
232
|
+
};
|
|
233
|
+
case "year":
|
|
234
|
+
return {
|
|
235
|
+
mask: "9999",
|
|
236
|
+
singlePattern: "yyyy",
|
|
237
|
+
};
|
|
238
|
+
case "date":
|
|
239
|
+
default:
|
|
240
|
+
return {
|
|
241
|
+
mask: "9999-99-99",
|
|
242
|
+
singlePattern: "yyyy-MM-dd",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function formatDateWithPattern(
|
|
248
|
+
date: Date,
|
|
249
|
+
pattern: string | undefined,
|
|
250
|
+
): string {
|
|
251
|
+
const p = pattern ?? "yyyy-MM-dd";
|
|
252
|
+
|
|
253
|
+
const yyyy = date.getFullYear().toString();
|
|
254
|
+
const MM = pad2(date.getMonth() + 1);
|
|
255
|
+
const dd = pad2(date.getDate());
|
|
256
|
+
const HH = pad2(date.getHours());
|
|
257
|
+
const mm = pad2(date.getMinutes());
|
|
258
|
+
|
|
259
|
+
return p
|
|
260
|
+
.replace(/yyyy/g, yyyy)
|
|
261
|
+
.replace(/MM/g, MM)
|
|
262
|
+
.replace(/dd/g, dd)
|
|
263
|
+
.replace(/HH/g, HH)
|
|
264
|
+
.replace(/mm/g, mm);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function formatDisplaySingle(
|
|
268
|
+
date: Date | undefined,
|
|
269
|
+
pattern?: string,
|
|
270
|
+
): string {
|
|
271
|
+
if (!date) return "";
|
|
272
|
+
return formatDateWithPattern(date, pattern);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function formatDisplayRange(
|
|
276
|
+
range: DateRange | undefined,
|
|
277
|
+
formatRange: DateVariantProps["formatRange"],
|
|
278
|
+
singlePattern?: string,
|
|
279
|
+
separator?: string,
|
|
280
|
+
): string {
|
|
281
|
+
if (!range || (!range.from && !range.to)) return "";
|
|
282
|
+
|
|
283
|
+
if (typeof formatRange === "function") {
|
|
284
|
+
return formatRange(range);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const pattern = formatRange ?? singlePattern ?? "yyyy-MM-dd";
|
|
288
|
+
const sep = separator ?? " – ";
|
|
289
|
+
|
|
290
|
+
const fromStr = range.from
|
|
291
|
+
? formatDateWithPattern(range.from, pattern)
|
|
292
|
+
: "";
|
|
293
|
+
const toStr = range.to
|
|
294
|
+
? formatDateWithPattern(range.to, pattern)
|
|
295
|
+
: "";
|
|
296
|
+
|
|
297
|
+
if (!fromStr && !toStr) return "";
|
|
298
|
+
if (!fromStr) return toStr;
|
|
299
|
+
if (!toStr) return fromStr;
|
|
300
|
+
|
|
301
|
+
return `${fromStr}${sep}${toStr}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Parse a raw digit string (unmasked) into a Date based on `kind`.
|
|
306
|
+
*
|
|
307
|
+
* Returns null when the input is incomplete or invalid.
|
|
308
|
+
*/
|
|
309
|
+
function parseRawToDate(rawDigits: string, kind: DateKind): Date | null {
|
|
310
|
+
const len = rawDigits.length;
|
|
311
|
+
|
|
312
|
+
switch (kind) {
|
|
313
|
+
case "datetime": {
|
|
314
|
+
if (len < 12) return null;
|
|
315
|
+
const year = Number(rawDigits.slice(0, 4));
|
|
316
|
+
const month = Number(rawDigits.slice(4, 6));
|
|
317
|
+
const day = Number(rawDigits.slice(6, 8));
|
|
318
|
+
const hour = Number(rawDigits.slice(8, 10));
|
|
319
|
+
const minute = Number(rawDigits.slice(10, 12));
|
|
320
|
+
if (!year || month < 1 || month > 12 || day < 1 || day > 31) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
return new Date(year, month - 1, day, hour, minute, 0, 0);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
case "time": {
|
|
330
|
+
if (len < 4) return null;
|
|
331
|
+
const hour = Number(rawDigits.slice(0, 2));
|
|
332
|
+
const minute = Number(rawDigits.slice(2, 4));
|
|
333
|
+
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const d = new Date();
|
|
337
|
+
d.setSeconds(0, 0);
|
|
338
|
+
d.setHours(hour, minute);
|
|
339
|
+
return d;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
case "hour": {
|
|
343
|
+
if (len < 2) return null;
|
|
344
|
+
const hour = Number(rawDigits.slice(0, 2));
|
|
345
|
+
if (hour < 0 || hour > 23) return null;
|
|
346
|
+
const d = new Date();
|
|
347
|
+
d.setSeconds(0, 0);
|
|
348
|
+
d.setHours(hour, 0);
|
|
349
|
+
return d;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
case "monthYear": {
|
|
353
|
+
if (len < 6) return null;
|
|
354
|
+
const month = Number(rawDigits.slice(0, 2));
|
|
355
|
+
const year = Number(rawDigits.slice(2, 6));
|
|
356
|
+
if (!year || month < 1 || month > 12) {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
return new Date(year, month - 1, 1, 0, 0, 0, 0);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
case "year": {
|
|
363
|
+
if (len < 4) return null;
|
|
364
|
+
const year = Number(rawDigits.slice(0, 4));
|
|
365
|
+
if (!year) return null;
|
|
366
|
+
return new Date(year, 0, 1, 0, 0, 0, 0);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
case "date":
|
|
370
|
+
default: {
|
|
371
|
+
if (len < 8) return null;
|
|
372
|
+
const year = Number(rawDigits.slice(0, 4));
|
|
373
|
+
const month = Number(rawDigits.slice(4, 6));
|
|
374
|
+
const day = Number(rawDigits.slice(6, 8));
|
|
375
|
+
if (!year || month < 1 || month > 12 || day < 1 || day > 31) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function meterSafeDigits(masked: string): string {
|
|
384
|
+
return masked.replace(/\D+/g, "");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ─────────────────────────────────────────────
|
|
388
|
+
// Component
|
|
389
|
+
// ─────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
export const ShadcnDateVariant = React.forwardRef<
|
|
392
|
+
HTMLInputElement,
|
|
393
|
+
ShadcnDateVariantProps
|
|
394
|
+
>(function ShadcnDateVariant(props, ref) {
|
|
395
|
+
const {
|
|
396
|
+
// variant base bits
|
|
397
|
+
value,
|
|
398
|
+
onValue,
|
|
399
|
+
error,
|
|
400
|
+
|
|
401
|
+
// date props
|
|
402
|
+
mode: modeProp = "single",
|
|
403
|
+
placeholder,
|
|
404
|
+
clearable = true,
|
|
405
|
+
minDate,
|
|
406
|
+
maxDate,
|
|
407
|
+
disabledDays,
|
|
408
|
+
formatSingle: formatSingleProp,
|
|
409
|
+
formatRange,
|
|
410
|
+
rangeSeparator,
|
|
411
|
+
stayOpenOnSelect,
|
|
412
|
+
open,
|
|
413
|
+
onOpenChange,
|
|
414
|
+
|
|
415
|
+
kind: kindProp = "date",
|
|
416
|
+
inputMask,
|
|
417
|
+
showCalendar: showCalendarProp,
|
|
418
|
+
|
|
419
|
+
//@ts-ignore text UI bits (size, density, className, icons, etc.)
|
|
420
|
+
className,
|
|
421
|
+
...restTextProps
|
|
422
|
+
} = props;
|
|
423
|
+
|
|
424
|
+
const mode: DateMode = modeProp ?? "single";
|
|
425
|
+
const kind: DateKind = kindProp ?? "date";
|
|
426
|
+
|
|
427
|
+
const kindConfig = resolveKindConfig(kind);
|
|
428
|
+
const singlePattern = formatSingleProp ?? kindConfig.singlePattern;
|
|
429
|
+
const resolvedMask = inputMask ?? kindConfig.mask;
|
|
430
|
+
|
|
431
|
+
const defaultShowCalendar =
|
|
432
|
+
kind === "date" || kind === "datetime";
|
|
433
|
+
const showCalendar =
|
|
434
|
+
typeof showCalendarProp === "boolean"
|
|
435
|
+
? showCalendarProp
|
|
436
|
+
: defaultShowCalendar;
|
|
437
|
+
|
|
438
|
+
const [internalOpen, setInternalOpen] = React.useState(false);
|
|
439
|
+
const isControlledOpen = open !== undefined;
|
|
440
|
+
const currentOpen = isControlledOpen ? !!open : internalOpen;
|
|
441
|
+
|
|
442
|
+
const handleOpenChange = React.useCallback(
|
|
443
|
+
(next: boolean) => {
|
|
444
|
+
if (!isControlledOpen) {
|
|
445
|
+
setInternalOpen(next);
|
|
446
|
+
}
|
|
447
|
+
onOpenChange?.(next);
|
|
448
|
+
},
|
|
449
|
+
[isControlledOpen, onOpenChange],
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const { single, range } = normalizeValueForMode(value, mode);
|
|
453
|
+
|
|
454
|
+
const displayValue = React.useMemo(() => {
|
|
455
|
+
if (mode === "single") {
|
|
456
|
+
return formatDisplaySingle(single, singlePattern);
|
|
457
|
+
}
|
|
458
|
+
return formatDisplayRange(
|
|
459
|
+
range,
|
|
460
|
+
formatRange,
|
|
461
|
+
singlePattern,
|
|
462
|
+
rangeSeparator,
|
|
463
|
+
);
|
|
464
|
+
}, [mode, single, range, singlePattern, formatRange, rangeSeparator]);
|
|
465
|
+
|
|
466
|
+
const [localText, setLocalText] = React.useState<string>(displayValue);
|
|
467
|
+
|
|
468
|
+
// Sync local text with external value / formatting
|
|
469
|
+
React.useEffect(() => {
|
|
470
|
+
setLocalText(displayValue);
|
|
471
|
+
}, [displayValue]);
|
|
472
|
+
|
|
473
|
+
// Time dropdown visibility:
|
|
474
|
+
// - Only for mode="single"
|
|
475
|
+
// - For datetime/time/hour kinds
|
|
476
|
+
const showTimeDropdowns =
|
|
477
|
+
mode === "single" &&
|
|
478
|
+
(kind === "datetime" || kind === "time" || kind === "hour");
|
|
479
|
+
|
|
480
|
+
const handleSelect = React.useCallback(
|
|
481
|
+
(next: Date | DateRange | undefined) => {
|
|
482
|
+
let nextValue: DateValue;
|
|
483
|
+
let nextRange: DateRange | undefined;
|
|
484
|
+
|
|
485
|
+
if (mode === "single") {
|
|
486
|
+
if (next instanceof Date) {
|
|
487
|
+
let selected = next;
|
|
488
|
+
|
|
489
|
+
// For datetime, preserve previously chosen time (if any)
|
|
490
|
+
if (kind === "datetime" && single) {
|
|
491
|
+
selected = new Date(
|
|
492
|
+
selected.getFullYear(),
|
|
493
|
+
selected.getMonth(),
|
|
494
|
+
selected.getDate(),
|
|
495
|
+
single.getHours(),
|
|
496
|
+
single.getMinutes(),
|
|
497
|
+
single.getSeconds(),
|
|
498
|
+
0,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
nextValue = selected;
|
|
503
|
+
} else {
|
|
504
|
+
nextValue = undefined;
|
|
505
|
+
}
|
|
506
|
+
nextRange = undefined;
|
|
507
|
+
} else {
|
|
508
|
+
if (next && next instanceof Date) {
|
|
509
|
+
nextRange = { from: next };
|
|
510
|
+
} else {
|
|
511
|
+
nextRange = (next as DateRange | undefined) ?? undefined;
|
|
512
|
+
}
|
|
513
|
+
nextValue = nextRange;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const rangeComplete =
|
|
517
|
+
mode === "range" ? isRangeComplete(nextRange) : !!nextValue;
|
|
518
|
+
|
|
519
|
+
const detail: ChangeDetail<{
|
|
520
|
+
mode: DateMode;
|
|
521
|
+
from: "calendar";
|
|
522
|
+
rangeComplete: boolean;
|
|
523
|
+
}> = {
|
|
524
|
+
source: "variant",
|
|
525
|
+
raw: nextValue,
|
|
526
|
+
nativeEvent: undefined,
|
|
527
|
+
meta: {
|
|
528
|
+
mode,
|
|
529
|
+
from: "calendar",
|
|
530
|
+
rangeComplete,
|
|
531
|
+
},
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
onValue?.(nextValue, detail);
|
|
535
|
+
|
|
536
|
+
const shouldStayOpen =
|
|
537
|
+
stayOpenOnSelect ||
|
|
538
|
+
(mode === "range" && !rangeComplete);
|
|
539
|
+
|
|
540
|
+
if (!shouldStayOpen) {
|
|
541
|
+
handleOpenChange(false);
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
[mode, stayOpenOnSelect, onValue, handleOpenChange, kind, single],
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const handleTimeChange = React.useCallback(
|
|
548
|
+
(next: Date | undefined) => {
|
|
549
|
+
if (!next) {
|
|
550
|
+
const detail: ChangeDetail<{
|
|
551
|
+
mode: DateMode;
|
|
552
|
+
kind: DateKind;
|
|
553
|
+
from: "time";
|
|
554
|
+
cleared: boolean;
|
|
555
|
+
}> = {
|
|
556
|
+
source: "variant",
|
|
557
|
+
raw: undefined,
|
|
558
|
+
nativeEvent: undefined,
|
|
559
|
+
meta: {
|
|
560
|
+
mode,
|
|
561
|
+
kind,
|
|
562
|
+
from: "time",
|
|
563
|
+
cleared: true,
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
onValue?.(undefined, detail);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const detail: ChangeDetail<{
|
|
571
|
+
mode: DateMode;
|
|
572
|
+
kind: DateKind;
|
|
573
|
+
from: "time";
|
|
574
|
+
}> = {
|
|
575
|
+
source: "variant",
|
|
576
|
+
raw: next,
|
|
577
|
+
nativeEvent: undefined,
|
|
578
|
+
meta: {
|
|
579
|
+
mode,
|
|
580
|
+
kind,
|
|
581
|
+
from: "time",
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
onValue?.(next, detail);
|
|
586
|
+
},
|
|
587
|
+
[mode, kind, onValue],
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
const handleClear = React.useCallback(
|
|
591
|
+
(ev: React.MouseEvent) => {
|
|
592
|
+
ev.preventDefault();
|
|
593
|
+
ev.stopPropagation();
|
|
594
|
+
|
|
595
|
+
const detail: ChangeDetail<{
|
|
596
|
+
mode: DateMode;
|
|
597
|
+
cleared: boolean;
|
|
598
|
+
}> = {
|
|
599
|
+
source: "variant",
|
|
600
|
+
raw: undefined,
|
|
601
|
+
nativeEvent: ev as any,
|
|
602
|
+
meta: {
|
|
603
|
+
mode,
|
|
604
|
+
cleared: true,
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
onValue?.(undefined, detail);
|
|
608
|
+
},
|
|
609
|
+
[mode, onValue],
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
const hasValue = hasSelection(value);
|
|
613
|
+
const placeholderText =
|
|
614
|
+
typeof placeholder === "string"
|
|
615
|
+
? placeholder
|
|
616
|
+
: mode === "range"
|
|
617
|
+
? "Select date range"
|
|
618
|
+
: "Select date";
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Manual text input (mask-driven) — only for `mode = "single"`.
|
|
622
|
+
* Range editing via text gets very hairy, so we keep range as
|
|
623
|
+
* a calendar-driven control for now.
|
|
624
|
+
*/
|
|
625
|
+
const handleInputChange = React.useCallback(
|
|
626
|
+
(event: any) => {
|
|
627
|
+
if (mode !== "single") return;
|
|
628
|
+
|
|
629
|
+
const masked = (event?.value ??
|
|
630
|
+
event?.target?.value ??
|
|
631
|
+
"") as string;
|
|
632
|
+
|
|
633
|
+
setLocalText(masked);
|
|
634
|
+
|
|
635
|
+
const digits = meterSafeDigits(masked);
|
|
636
|
+
|
|
637
|
+
if (!digits.length) {
|
|
638
|
+
const detail: ChangeDetail<{
|
|
639
|
+
mode: DateMode;
|
|
640
|
+
kind: DateKind;
|
|
641
|
+
from: "text";
|
|
642
|
+
cleared: boolean;
|
|
643
|
+
}> = {
|
|
644
|
+
source: "variant",
|
|
645
|
+
raw: undefined,
|
|
646
|
+
nativeEvent: event,
|
|
647
|
+
meta: {
|
|
648
|
+
mode,
|
|
649
|
+
kind,
|
|
650
|
+
from: "text",
|
|
651
|
+
cleared: true,
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
onValue?.(undefined, detail);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const parsed = parseRawToDate(digits, kind);
|
|
659
|
+
if (!parsed) {
|
|
660
|
+
// Incomplete or invalid — keep local text but don't
|
|
661
|
+
// push a Date value yet.
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// If min/max are set, enforce them here.
|
|
666
|
+
if (minDate && parsed < minDate) return;
|
|
667
|
+
if (maxDate && parsed > maxDate) return;
|
|
668
|
+
|
|
669
|
+
const detail: ChangeDetail<{
|
|
670
|
+
mode: DateMode;
|
|
671
|
+
kind: DateKind;
|
|
672
|
+
from: "text";
|
|
673
|
+
}> = {
|
|
674
|
+
source: "variant",
|
|
675
|
+
raw: parsed,
|
|
676
|
+
nativeEvent: event,
|
|
677
|
+
meta: {
|
|
678
|
+
mode,
|
|
679
|
+
kind,
|
|
680
|
+
from: "text",
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
onValue?.(parsed, detail);
|
|
685
|
+
},
|
|
686
|
+
[mode, kind, minDate, maxDate, onValue],
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
const trailingControl = (
|
|
690
|
+
<div
|
|
691
|
+
className="flex h-full items-center gap-1 pr-1"
|
|
692
|
+
data-slot="date-controls"
|
|
693
|
+
>
|
|
694
|
+
{clearable && hasValue && (
|
|
695
|
+
<button
|
|
696
|
+
type="button"
|
|
697
|
+
onClick={handleClear}
|
|
698
|
+
className="inline-flex h-6 w-6 items-center justify-center rounded-full text-xs text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
|
699
|
+
aria-label="Clear date"
|
|
700
|
+
data-slot="date-clear"
|
|
701
|
+
>
|
|
702
|
+
<XIcon className="h-3 w-3" />
|
|
703
|
+
</button>
|
|
704
|
+
)}
|
|
705
|
+
|
|
706
|
+
{showCalendar && (
|
|
707
|
+
<button
|
|
708
|
+
type="button"
|
|
709
|
+
onClick={() => handleOpenChange(!currentOpen)}
|
|
710
|
+
className="inline-flex h-6 w-6 items-center justify-center rounded-full text-xs text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
|
711
|
+
aria-label="Open calendar"
|
|
712
|
+
data-slot="date-toggle"
|
|
713
|
+
>
|
|
714
|
+
<CalendarIcon className="h-4 w-4" />
|
|
715
|
+
</button>
|
|
716
|
+
)}
|
|
717
|
+
</div>
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
const inputNode = (
|
|
721
|
+
<Input
|
|
722
|
+
ref={ref}
|
|
723
|
+
{...restTextProps}
|
|
724
|
+
type="text"
|
|
725
|
+
value={localText}
|
|
726
|
+
onChange={mode === "single" ? (handleInputChange as any) : undefined}
|
|
727
|
+
readOnly={mode !== "single" && showCalendar}
|
|
728
|
+
placeholder={placeholderText}
|
|
729
|
+
trailingControl={trailingControl}
|
|
730
|
+
aria-invalid={error ? "true" : undefined}
|
|
731
|
+
// Mask only makes sense when we allow typing.
|
|
732
|
+
mask={mode === "single" ? resolvedMask : undefined}
|
|
733
|
+
/>
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
// If calendar is disabled completely, just render the masked input.
|
|
737
|
+
if (!showCalendar) {
|
|
738
|
+
return (
|
|
739
|
+
<div className={className} data-slot="date-field">
|
|
740
|
+
{inputNode}
|
|
741
|
+
</div>
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const showCalendarBody = kind !== "time" && kind !== "hour";
|
|
746
|
+
|
|
747
|
+
// Calendar / time popover.
|
|
748
|
+
return (
|
|
749
|
+
<Popover open={currentOpen} onOpenChange={handleOpenChange}>
|
|
750
|
+
<PopoverTrigger asChild>
|
|
751
|
+
<div className={className} data-slot="date-field">
|
|
752
|
+
{inputNode}
|
|
753
|
+
</div>
|
|
754
|
+
</PopoverTrigger>
|
|
755
|
+
<PopoverContent
|
|
756
|
+
align="start"
|
|
757
|
+
className="w-auto p-0"
|
|
758
|
+
data-slot="date-popover"
|
|
759
|
+
>
|
|
760
|
+
<div className="flex flex-col gap-2 p-2">
|
|
761
|
+
{showCalendarBody && (
|
|
762
|
+
<Calendar
|
|
763
|
+
mode={mode}
|
|
764
|
+
//@ts-ignore date UI bits
|
|
765
|
+
selected={mode === "single" ? single : range}
|
|
766
|
+
onSelect={handleSelect as any}
|
|
767
|
+
disabled={disabledDays}
|
|
768
|
+
fromDate={minDate}
|
|
769
|
+
toDate={maxDate}
|
|
770
|
+
initialFocus
|
|
771
|
+
/>
|
|
772
|
+
)}
|
|
773
|
+
|
|
774
|
+
{showTimeDropdowns && (
|
|
775
|
+
<TimeDropdowns
|
|
776
|
+
value={single ?? undefined}
|
|
777
|
+
onChange={handleTimeChange}
|
|
778
|
+
label={
|
|
779
|
+
kind === "datetime"
|
|
780
|
+
? "Time"
|
|
781
|
+
: undefined
|
|
782
|
+
}
|
|
783
|
+
minuteStep={5}
|
|
784
|
+
showSeconds={false}
|
|
785
|
+
density="compact"
|
|
786
|
+
/>
|
|
787
|
+
)}
|
|
788
|
+
</div>
|
|
789
|
+
</PopoverContent>
|
|
790
|
+
</Popover>
|
|
791
|
+
);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
ShadcnDateVariant.displayName = "ShadcnDateVariant";
|
|
795
|
+
|
|
796
|
+
export default ShadcnDateVariant;
|