@vvedantb/ui 0.13.1 → 0.15.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 +22 -5
- package/app/README.md +1 -1
- package/package.json +6 -1
- package/shell/src/app-shell-classes.ts +2 -2
- package/src/index.ts +2 -0
- package/src/ui/_picker-classes.ts +18 -0
- package/src/ui/calendar.tsx +161 -0
- package/src/ui/clearInputDissolve.ts +10 -5
- package/src/ui/date-picker.tsx +545 -0
- package/src/ui/dialog.tsx +5 -1
- package/src/ui/time-picker.tsx +12 -5
- package/tokens/README.md +30 -8
- package/tokens/src/css/clear-input.css +71 -0
- package/tokens/src/css/compat-shadcn-rgb.css +8 -7
- package/tokens/src/css/index.css +8 -5
- package/tokens/src/css/modal.css +3 -2
- package/tokens/src/css/motion.css +156 -0
- package/tokens/src/css/scrollbar-thin.css +54 -26
- package/tokens/src/css/tabs.css +16 -5
- package/tokens/src/tokens.css +52 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
IconCalendar,
|
|
4
|
+
IconCalendarMonth,
|
|
5
|
+
IconChevronLeft,
|
|
6
|
+
IconChevronRight,
|
|
7
|
+
IconX,
|
|
8
|
+
} from "@tabler/icons-react";
|
|
9
|
+
import type { Matcher } from "react-day-picker";
|
|
10
|
+
import { cn } from "../utils/cn";
|
|
11
|
+
import { assignRef } from "../utils/ref";
|
|
12
|
+
import {
|
|
13
|
+
pickerFocusClass,
|
|
14
|
+
pickerFieldClass,
|
|
15
|
+
pickerItemHoverClass,
|
|
16
|
+
pickerSelectedClass,
|
|
17
|
+
pickerTriggerClass,
|
|
18
|
+
} from "./_picker-classes";
|
|
19
|
+
import { Calendar } from "./calendar";
|
|
20
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
21
|
+
import { TimePicker } from "./time-picker";
|
|
22
|
+
|
|
23
|
+
// controlled replacements for `<input type="date">`, `"datetime-local"` and
|
|
24
|
+
// `"month">`: the values are the native strings ("YYYY-MM-DD",
|
|
25
|
+
// "YYYY-MM-DDTHH:mm", "YYYY-MM", "" when empty), read and written as local
|
|
26
|
+
// calendar dates. there is no timezone logic; like TimePicker, the consumer
|
|
27
|
+
// owns any local↔utc conversion
|
|
28
|
+
|
|
29
|
+
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
30
|
+
const MONTH_RE = /^(\d{4})-(\d{2})$/;
|
|
31
|
+
const DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})$/;
|
|
32
|
+
|
|
33
|
+
const pad = (n: number, width = 2) => String(n).padStart(width, "0");
|
|
34
|
+
|
|
35
|
+
// "YYYY-MM-DD" -> local midnight, or undefined for "" and invalid dates
|
|
36
|
+
// (2025-02-30). never `new Date("YYYY-MM-DD")`, which parses as utc midnight
|
|
37
|
+
// and lands on the previous day west of greenwich
|
|
38
|
+
function parseDateValue(value: string): Date | undefined {
|
|
39
|
+
const match = DATE_RE.exec(value);
|
|
40
|
+
if (!match) return undefined;
|
|
41
|
+
const year = Number(match[1]);
|
|
42
|
+
const month = Number(match[2]) - 1;
|
|
43
|
+
const day = Number(match[3]);
|
|
44
|
+
// setFullYear keeps years 0-99 literal (the Date constructor maps them to 19xx)
|
|
45
|
+
const date = new Date(2000, 0, 1);
|
|
46
|
+
date.setFullYear(year, month, day);
|
|
47
|
+
if (
|
|
48
|
+
date.getFullYear() !== year ||
|
|
49
|
+
date.getMonth() !== month ||
|
|
50
|
+
date.getDate() !== day
|
|
51
|
+
) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return date;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// local calendar date -> "YYYY-MM-DD" ("" for an invalid date)
|
|
58
|
+
function formatDateValue(date: Date): string {
|
|
59
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
60
|
+
return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseMonthValue(
|
|
64
|
+
value: string,
|
|
65
|
+
): { year: number; month: number } | undefined {
|
|
66
|
+
const match = MONTH_RE.exec(value);
|
|
67
|
+
if (!match) return undefined;
|
|
68
|
+
const year = Number(match[1]);
|
|
69
|
+
const month = Number(match[2]) - 1;
|
|
70
|
+
if (month < 0 || month > 11) return undefined;
|
|
71
|
+
return { year, month };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const formatMonthValue = (year: number, month: number) =>
|
|
75
|
+
`${pad(year, 4)}-${pad(month + 1)}`;
|
|
76
|
+
|
|
77
|
+
function localDate(year: number, month: number): Date {
|
|
78
|
+
const date = new Date(2000, 0, 1);
|
|
79
|
+
date.setFullYear(year, month, 1);
|
|
80
|
+
return date;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// wrapper, input-styled trigger, optional clear control and hidden input
|
|
84
|
+
// shared by DatePicker and MonthPicker. the clear control is a sibling of the
|
|
85
|
+
// trigger (buttons cannot nest), overlaid inside the field on the right, and
|
|
86
|
+
// hands focus back to the trigger. the trigger grows with the wrapper, so a
|
|
87
|
+
// width on either `className` or `triggerClassName` sizes the field
|
|
88
|
+
interface PickerFieldProps {
|
|
89
|
+
triggerRef: React.Ref<HTMLButtonElement>;
|
|
90
|
+
open: boolean;
|
|
91
|
+
onOpenChange: (open: boolean) => void;
|
|
92
|
+
value: string;
|
|
93
|
+
text: string | undefined;
|
|
94
|
+
placeholder: string;
|
|
95
|
+
icon: React.ReactNode;
|
|
96
|
+
clearLabel: string;
|
|
97
|
+
onClear: () => void;
|
|
98
|
+
clearable?: boolean;
|
|
99
|
+
disabled?: boolean;
|
|
100
|
+
ariaLabel?: string;
|
|
101
|
+
className?: string;
|
|
102
|
+
triggerClassName?: string;
|
|
103
|
+
id?: string;
|
|
104
|
+
name?: string;
|
|
105
|
+
children: React.ReactNode;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function PickerField({
|
|
109
|
+
triggerRef,
|
|
110
|
+
open,
|
|
111
|
+
onOpenChange,
|
|
112
|
+
value,
|
|
113
|
+
text,
|
|
114
|
+
placeholder,
|
|
115
|
+
icon,
|
|
116
|
+
clearLabel,
|
|
117
|
+
onClear,
|
|
118
|
+
clearable,
|
|
119
|
+
disabled,
|
|
120
|
+
ariaLabel,
|
|
121
|
+
className,
|
|
122
|
+
triggerClassName,
|
|
123
|
+
id,
|
|
124
|
+
name,
|
|
125
|
+
children,
|
|
126
|
+
}: PickerFieldProps) {
|
|
127
|
+
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
|
|
128
|
+
const showClear = clearable && text !== undefined && !disabled;
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className={cn(pickerFieldClass, className)}>
|
|
132
|
+
<Popover open={open} onOpenChange={onOpenChange}>
|
|
133
|
+
<PopoverTrigger asChild>
|
|
134
|
+
<button
|
|
135
|
+
ref={(node) => {
|
|
136
|
+
buttonRef.current = node;
|
|
137
|
+
assignRef(triggerRef, node);
|
|
138
|
+
}}
|
|
139
|
+
id={id}
|
|
140
|
+
type="button"
|
|
141
|
+
disabled={disabled}
|
|
142
|
+
aria-label={
|
|
143
|
+
ariaLabel ? `${ariaLabel}, ${text ?? placeholder}` : undefined
|
|
144
|
+
}
|
|
145
|
+
className={cn(pickerTriggerClass, "w-40 min-w-0 grow", triggerClassName)}
|
|
146
|
+
>
|
|
147
|
+
<span
|
|
148
|
+
className={cn(
|
|
149
|
+
"truncate",
|
|
150
|
+
text === undefined && "text-field-placeholder",
|
|
151
|
+
showClear && "pr-7",
|
|
152
|
+
)}
|
|
153
|
+
>
|
|
154
|
+
{text ?? placeholder}
|
|
155
|
+
</span>
|
|
156
|
+
{icon}
|
|
157
|
+
</button>
|
|
158
|
+
</PopoverTrigger>
|
|
159
|
+
<PopoverContent align="start" sideOffset={4} className="w-auto p-2">
|
|
160
|
+
{children}
|
|
161
|
+
</PopoverContent>
|
|
162
|
+
</Popover>
|
|
163
|
+
{showClear ? (
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
aria-label={clearLabel}
|
|
167
|
+
onClick={() => {
|
|
168
|
+
onClear();
|
|
169
|
+
buttonRef.current?.focus();
|
|
170
|
+
}}
|
|
171
|
+
className={cn(
|
|
172
|
+
"absolute right-8 top-1/2 inline-flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-surface-tertiary hover:text-foreground",
|
|
173
|
+
pickerFocusClass,
|
|
174
|
+
)}
|
|
175
|
+
>
|
|
176
|
+
<IconX className="size-3" />
|
|
177
|
+
</button>
|
|
178
|
+
) : null}
|
|
179
|
+
{name ? <input type="hidden" name={name} value={value} /> : null}
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface DatePickerProps {
|
|
185
|
+
// "YYYY-MM-DD" or "" (what `<input type="date">` uses)
|
|
186
|
+
value: string;
|
|
187
|
+
onChange: (value: string) => void;
|
|
188
|
+
// "YYYY-MM-DD"; days outside the range are disabled
|
|
189
|
+
min?: string;
|
|
190
|
+
max?: string;
|
|
191
|
+
placeholder?: string;
|
|
192
|
+
disabled?: boolean;
|
|
193
|
+
// names the trigger; the shown date or placeholder is appended
|
|
194
|
+
ariaLabel?: string;
|
|
195
|
+
// the wrapper (trigger plus clear control)
|
|
196
|
+
className?: string;
|
|
197
|
+
// merged onto the trigger (default width `w-40`); `w-full`, height, radius,
|
|
198
|
+
// padding and text size override cleanly
|
|
199
|
+
triggerClassName?: string;
|
|
200
|
+
// shows a clear control that calls `onChange("")`
|
|
201
|
+
clearable?: boolean;
|
|
202
|
+
// BCP 47 locale for the trigger text (default: the runtime's)
|
|
203
|
+
locale?: string;
|
|
204
|
+
id?: string;
|
|
205
|
+
// renders a hidden input with `value` so forms still submit it
|
|
206
|
+
name?: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
|
|
210
|
+
(
|
|
211
|
+
{
|
|
212
|
+
value,
|
|
213
|
+
onChange,
|
|
214
|
+
min,
|
|
215
|
+
max,
|
|
216
|
+
placeholder = "Pick a date",
|
|
217
|
+
disabled,
|
|
218
|
+
ariaLabel,
|
|
219
|
+
className,
|
|
220
|
+
triggerClassName,
|
|
221
|
+
clearable,
|
|
222
|
+
locale,
|
|
223
|
+
id,
|
|
224
|
+
name,
|
|
225
|
+
},
|
|
226
|
+
ref,
|
|
227
|
+
) => {
|
|
228
|
+
const [open, setOpen] = React.useState(false);
|
|
229
|
+
const selected = parseDateValue(value);
|
|
230
|
+
const minDate = min ? parseDateValue(min) : undefined;
|
|
231
|
+
const maxDate = max ? parseDateValue(max) : undefined;
|
|
232
|
+
const outOfRange: Matcher[] = [
|
|
233
|
+
...(minDate ? [{ before: minDate }] : []),
|
|
234
|
+
...(maxDate ? [{ after: maxDate }] : []),
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<PickerField
|
|
239
|
+
triggerRef={ref}
|
|
240
|
+
open={open}
|
|
241
|
+
onOpenChange={setOpen}
|
|
242
|
+
value={value}
|
|
243
|
+
text={
|
|
244
|
+
selected
|
|
245
|
+
? new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(
|
|
246
|
+
selected,
|
|
247
|
+
)
|
|
248
|
+
: undefined
|
|
249
|
+
}
|
|
250
|
+
placeholder={placeholder}
|
|
251
|
+
icon={<IconCalendar className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
|
|
252
|
+
clearLabel="Clear date"
|
|
253
|
+
onClear={() => onChange("")}
|
|
254
|
+
clearable={clearable}
|
|
255
|
+
disabled={disabled}
|
|
256
|
+
ariaLabel={ariaLabel}
|
|
257
|
+
className={className}
|
|
258
|
+
triggerClassName={triggerClassName}
|
|
259
|
+
id={id}
|
|
260
|
+
name={name}
|
|
261
|
+
>
|
|
262
|
+
<Calendar
|
|
263
|
+
mode="single"
|
|
264
|
+
required
|
|
265
|
+
selected={selected}
|
|
266
|
+
defaultMonth={selected ?? minDate}
|
|
267
|
+
startMonth={minDate}
|
|
268
|
+
endMonth={maxDate}
|
|
269
|
+
disabled={outOfRange}
|
|
270
|
+
autoFocus
|
|
271
|
+
onSelect={(date) => {
|
|
272
|
+
onChange(formatDateValue(date));
|
|
273
|
+
setOpen(false);
|
|
274
|
+
}}
|
|
275
|
+
/>
|
|
276
|
+
</PickerField>
|
|
277
|
+
);
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
DatePicker.displayName = "DatePicker";
|
|
281
|
+
|
|
282
|
+
interface DateTimePickerProps {
|
|
283
|
+
// "YYYY-MM-DDTHH:mm" or "" (what `<input type="datetime-local">` uses)
|
|
284
|
+
value: string;
|
|
285
|
+
onChange: (value: string) => void;
|
|
286
|
+
// "YYYY-MM-DDTHH:mm"; only the date part limits the calendar
|
|
287
|
+
min?: string;
|
|
288
|
+
max?: string;
|
|
289
|
+
// time used when a date is picked before any time (default "00:00")
|
|
290
|
+
defaultTime?: string;
|
|
291
|
+
disabled?: boolean;
|
|
292
|
+
// names both triggers ("<label>, date" and "<label>, time")
|
|
293
|
+
ariaLabel?: string;
|
|
294
|
+
// the row; a width here (`w-full`) grows the date field, the time keeps 110px
|
|
295
|
+
className?: string;
|
|
296
|
+
// merged onto both triggers (height, radius, padding, text size); a width
|
|
297
|
+
// applies to the date trigger only, the time keeps 110px
|
|
298
|
+
triggerClassName?: string;
|
|
299
|
+
// on the date trigger
|
|
300
|
+
id?: string;
|
|
301
|
+
// renders a hidden input with the combined value
|
|
302
|
+
name?: string;
|
|
303
|
+
locale?: string;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function splitDateTime(value: string): { date: string; time: string } {
|
|
307
|
+
const match = DATE_TIME_RE.exec(value);
|
|
308
|
+
if (!match) return { date: "", time: "" };
|
|
309
|
+
return { date: match[1] ?? "", time: match[2] ?? "" };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const DateTimePicker = React.forwardRef<HTMLButtonElement, DateTimePickerProps>(
|
|
313
|
+
(
|
|
314
|
+
{
|
|
315
|
+
value,
|
|
316
|
+
onChange,
|
|
317
|
+
min,
|
|
318
|
+
max,
|
|
319
|
+
defaultTime = "00:00",
|
|
320
|
+
disabled,
|
|
321
|
+
ariaLabel,
|
|
322
|
+
className,
|
|
323
|
+
triggerClassName,
|
|
324
|
+
id,
|
|
325
|
+
name,
|
|
326
|
+
locale,
|
|
327
|
+
},
|
|
328
|
+
ref,
|
|
329
|
+
) => {
|
|
330
|
+
const { date, time } = splitDateTime(value);
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<div className={cn(pickerFieldClass, "items-center gap-2", className)}>
|
|
334
|
+
<DatePicker
|
|
335
|
+
className="min-w-0 grow"
|
|
336
|
+
triggerClassName={triggerClassName}
|
|
337
|
+
ref={ref}
|
|
338
|
+
id={id}
|
|
339
|
+
value={date}
|
|
340
|
+
onChange={(next) => onChange(next ? `${next}T${time || defaultTime}` : "")}
|
|
341
|
+
min={min?.slice(0, 10)}
|
|
342
|
+
max={max?.slice(0, 10)}
|
|
343
|
+
disabled={disabled}
|
|
344
|
+
ariaLabel={ariaLabel ? `${ariaLabel}, date` : "Date"}
|
|
345
|
+
locale={locale}
|
|
346
|
+
/>
|
|
347
|
+
<TimePicker
|
|
348
|
+
value={time || defaultTime}
|
|
349
|
+
onChange={(next) =>
|
|
350
|
+
onChange(`${date || formatDateValue(new Date())}T${next}`)
|
|
351
|
+
}
|
|
352
|
+
disabled={disabled}
|
|
353
|
+
ariaLabel={ariaLabel ? `${ariaLabel}, time` : "Time"}
|
|
354
|
+
className="shrink-0"
|
|
355
|
+
triggerClassName={cn(triggerClassName, "w-[110px]")}
|
|
356
|
+
/>
|
|
357
|
+
{name ? <input type="hidden" name={name} value={value} /> : null}
|
|
358
|
+
</div>
|
|
359
|
+
);
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
DateTimePicker.displayName = "DateTimePicker";
|
|
363
|
+
|
|
364
|
+
interface MonthPickerProps {
|
|
365
|
+
// "YYYY-MM" or "" (what `<input type="month">` uses)
|
|
366
|
+
value: string;
|
|
367
|
+
onChange: (value: string) => void;
|
|
368
|
+
// "YYYY-MM"; months outside the range are disabled
|
|
369
|
+
min?: string;
|
|
370
|
+
max?: string;
|
|
371
|
+
placeholder?: string;
|
|
372
|
+
disabled?: boolean;
|
|
373
|
+
ariaLabel?: string;
|
|
374
|
+
className?: string;
|
|
375
|
+
// merged onto the trigger, as on DatePicker
|
|
376
|
+
triggerClassName?: string;
|
|
377
|
+
clearable?: boolean;
|
|
378
|
+
locale?: string;
|
|
379
|
+
id?: string;
|
|
380
|
+
name?: string;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const MonthPicker = React.forwardRef<HTMLButtonElement, MonthPickerProps>(
|
|
384
|
+
(
|
|
385
|
+
{
|
|
386
|
+
value,
|
|
387
|
+
onChange,
|
|
388
|
+
min,
|
|
389
|
+
max,
|
|
390
|
+
placeholder = "Pick a month",
|
|
391
|
+
disabled,
|
|
392
|
+
ariaLabel,
|
|
393
|
+
className,
|
|
394
|
+
triggerClassName,
|
|
395
|
+
clearable,
|
|
396
|
+
locale,
|
|
397
|
+
id,
|
|
398
|
+
name,
|
|
399
|
+
},
|
|
400
|
+
ref,
|
|
401
|
+
) => {
|
|
402
|
+
const [open, setOpen] = React.useState(false);
|
|
403
|
+
const selected = parseMonthValue(value);
|
|
404
|
+
|
|
405
|
+
return (
|
|
406
|
+
<PickerField
|
|
407
|
+
triggerRef={ref}
|
|
408
|
+
open={open}
|
|
409
|
+
onOpenChange={setOpen}
|
|
410
|
+
value={value}
|
|
411
|
+
text={
|
|
412
|
+
selected
|
|
413
|
+
? new Intl.DateTimeFormat(locale, {
|
|
414
|
+
month: "short",
|
|
415
|
+
year: "numeric",
|
|
416
|
+
}).format(localDate(selected.year, selected.month))
|
|
417
|
+
: undefined
|
|
418
|
+
}
|
|
419
|
+
placeholder={placeholder}
|
|
420
|
+
icon={
|
|
421
|
+
<IconCalendarMonth className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
422
|
+
}
|
|
423
|
+
clearLabel="Clear month"
|
|
424
|
+
onClear={() => onChange("")}
|
|
425
|
+
clearable={clearable}
|
|
426
|
+
disabled={disabled}
|
|
427
|
+
ariaLabel={ariaLabel}
|
|
428
|
+
className={className}
|
|
429
|
+
triggerClassName={triggerClassName}
|
|
430
|
+
id={id}
|
|
431
|
+
name={name}
|
|
432
|
+
>
|
|
433
|
+
<MonthGrid
|
|
434
|
+
value={value}
|
|
435
|
+
min={min}
|
|
436
|
+
max={max}
|
|
437
|
+
locale={locale}
|
|
438
|
+
onSelect={(next) => {
|
|
439
|
+
onChange(next);
|
|
440
|
+
setOpen(false);
|
|
441
|
+
}}
|
|
442
|
+
/>
|
|
443
|
+
</PickerField>
|
|
444
|
+
);
|
|
445
|
+
},
|
|
446
|
+
);
|
|
447
|
+
MonthPicker.displayName = "MonthPicker";
|
|
448
|
+
|
|
449
|
+
interface MonthGridProps {
|
|
450
|
+
value: string;
|
|
451
|
+
min?: string;
|
|
452
|
+
max?: string;
|
|
453
|
+
locale?: string;
|
|
454
|
+
onSelect: (value: string) => void;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// mounted only while the popover is open, so the shown year starts at the
|
|
458
|
+
// selected one (else the current year, kept inside min/max) on every open
|
|
459
|
+
function MonthGrid({ value, min, max, locale, onSelect }: MonthGridProps) {
|
|
460
|
+
const minYear = parseMonthValue(min ?? "")?.year;
|
|
461
|
+
const maxYear = parseMonthValue(max ?? "")?.year;
|
|
462
|
+
const [year, setYear] = React.useState(() => {
|
|
463
|
+
const initial =
|
|
464
|
+
parseMonthValue(value)?.year ?? new Date().getFullYear();
|
|
465
|
+
if (maxYear !== undefined && initial > maxYear) return maxYear;
|
|
466
|
+
if (minYear !== undefined && initial < minYear) return minYear;
|
|
467
|
+
return initial;
|
|
468
|
+
});
|
|
469
|
+
const short = new Intl.DateTimeFormat(locale, { month: "short" });
|
|
470
|
+
const long = new Intl.DateTimeFormat(locale, {
|
|
471
|
+
month: "long",
|
|
472
|
+
year: "numeric",
|
|
473
|
+
});
|
|
474
|
+
const navClass = cn(
|
|
475
|
+
"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-surface-tertiary hover:text-foreground disabled:pointer-events-none disabled:opacity-50",
|
|
476
|
+
pickerFocusClass,
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
return (
|
|
480
|
+
<div className="flex w-56 flex-col gap-2 p-1">
|
|
481
|
+
<div className="flex items-center justify-between">
|
|
482
|
+
<button
|
|
483
|
+
type="button"
|
|
484
|
+
aria-label="Previous year"
|
|
485
|
+
disabled={minYear !== undefined && year <= minYear}
|
|
486
|
+
onClick={() => setYear(year - 1)}
|
|
487
|
+
className={navClass}
|
|
488
|
+
>
|
|
489
|
+
<IconChevronLeft className="size-4" />
|
|
490
|
+
</button>
|
|
491
|
+
<div aria-live="polite" className="text-xs font-medium tabular-nums">
|
|
492
|
+
{year}
|
|
493
|
+
</div>
|
|
494
|
+
<button
|
|
495
|
+
type="button"
|
|
496
|
+
aria-label="Next year"
|
|
497
|
+
disabled={maxYear !== undefined && year >= maxYear}
|
|
498
|
+
onClick={() => setYear(year + 1)}
|
|
499
|
+
className={navClass}
|
|
500
|
+
>
|
|
501
|
+
<IconChevronRight className="size-4" />
|
|
502
|
+
</button>
|
|
503
|
+
</div>
|
|
504
|
+
<div role="group" aria-label={String(year)} className="grid grid-cols-3 gap-1">
|
|
505
|
+
{Array.from({ length: 12 }, (_, month) => {
|
|
506
|
+
const monthValue = formatMonthValue(year, month);
|
|
507
|
+
const date = localDate(year, month);
|
|
508
|
+
const isSelected = monthValue === value;
|
|
509
|
+
// "YYYY-MM" strings compare in calendar order
|
|
510
|
+
const outOfRange =
|
|
511
|
+
(!!min && monthValue < min) || (!!max && monthValue > max);
|
|
512
|
+
return (
|
|
513
|
+
<button
|
|
514
|
+
key={month}
|
|
515
|
+
type="button"
|
|
516
|
+
aria-label={long.format(date)}
|
|
517
|
+
aria-pressed={isSelected}
|
|
518
|
+
disabled={outOfRange}
|
|
519
|
+
data-month-value={monthValue}
|
|
520
|
+
onClick={() => onSelect(monthValue)}
|
|
521
|
+
className={cn(
|
|
522
|
+
"flex h-8 items-center justify-center rounded-lg text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-40",
|
|
523
|
+
pickerFocusClass,
|
|
524
|
+
isSelected
|
|
525
|
+
? pickerSelectedClass
|
|
526
|
+
: cn("text-foreground/80", pickerItemHoverClass),
|
|
527
|
+
)}
|
|
528
|
+
>
|
|
529
|
+
{short.format(date)}
|
|
530
|
+
</button>
|
|
531
|
+
);
|
|
532
|
+
})}
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export {
|
|
539
|
+
DatePicker,
|
|
540
|
+
DateTimePicker,
|
|
541
|
+
MonthPicker,
|
|
542
|
+
parseDateValue,
|
|
543
|
+
formatDateValue,
|
|
544
|
+
};
|
|
545
|
+
export type { DatePickerProps, DateTimePickerProps, MonthPickerProps };
|
package/src/ui/dialog.tsx
CHANGED
|
@@ -211,6 +211,10 @@ const DialogBody = ({
|
|
|
211
211
|
);
|
|
212
212
|
DialogBody.displayName = "DialogBody";
|
|
213
213
|
|
|
214
|
+
// no fill of its own: the footer is not sticky, so it shows the content's
|
|
215
|
+
// surface. a token here (it was `bg-overlay`) showed as an inset block when
|
|
216
|
+
// that surface differs (an app's own `glass-panel-strong`, or `bg-*` on the
|
|
217
|
+
// content)
|
|
214
218
|
const DialogFooter = ({
|
|
215
219
|
className,
|
|
216
220
|
...props
|
|
@@ -218,7 +222,7 @@ const DialogFooter = ({
|
|
|
218
222
|
<div
|
|
219
223
|
data-slot="dialog-footer"
|
|
220
224
|
className={cn(
|
|
221
|
-
"mt-auto flex flex-col-reverse gap-2
|
|
225
|
+
"mt-auto flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end",
|
|
222
226
|
"pb-[max(0px,env(safe-area-inset-bottom,0px))]",
|
|
223
227
|
"[&_button]:max-sm:min-h-11",
|
|
224
228
|
className,
|
package/src/ui/time-picker.tsx
CHANGED
|
@@ -2,6 +2,11 @@ import * as React from "react";
|
|
|
2
2
|
import { IconClock } from "@tabler/icons-react";
|
|
3
3
|
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
4
4
|
import { cn } from "../utils/cn";
|
|
5
|
+
import {
|
|
6
|
+
pickerItemHoverClass,
|
|
7
|
+
pickerSelectedClass,
|
|
8
|
+
pickerTriggerClass,
|
|
9
|
+
} from "./_picker-classes";
|
|
5
10
|
|
|
6
11
|
// custom 24, hour "hh:mm" time picker replaces `<input type="time">` because the native picker varies wildly
|
|
7
12
|
// across browsers and platforms (chrome/firefox/safari each render a
|
|
@@ -15,7 +20,8 @@ interface TimePickerProps {
|
|
|
15
20
|
className?: string;
|
|
16
21
|
disabled?: boolean;
|
|
17
22
|
ariaLabel?: string;
|
|
18
|
-
//
|
|
23
|
+
// merged onto the trigger (width defaults to a compact 110px; height,
|
|
24
|
+
// radius, padding and text size override cleanly)
|
|
19
25
|
triggerClassName?: string;
|
|
20
26
|
}
|
|
21
27
|
|
|
@@ -88,8 +94,9 @@ const TimePicker = React.forwardRef<HTMLButtonElement, TimePickerProps>(
|
|
|
88
94
|
disabled={disabled}
|
|
89
95
|
aria-label={ariaLabel}
|
|
90
96
|
className={cn(
|
|
91
|
-
|
|
92
|
-
|
|
97
|
+
pickerTriggerClass,
|
|
98
|
+
"w-[110px]",
|
|
99
|
+
triggerClassName,
|
|
93
100
|
className,
|
|
94
101
|
)}
|
|
95
102
|
>
|
|
@@ -152,8 +159,8 @@ const TimeColumn = React.forwardRef<HTMLDivElement, TimeColumnProps>(
|
|
|
152
159
|
className={cn(
|
|
153
160
|
"flex h-7 w-full items-center justify-center text-xs tabular-nums transition-colors",
|
|
154
161
|
isSelected
|
|
155
|
-
?
|
|
156
|
-
: "text-foreground/80
|
|
162
|
+
? pickerSelectedClass
|
|
163
|
+
: cn("text-foreground/80", pickerItemHoverClass),
|
|
157
164
|
)}
|
|
158
165
|
>
|
|
159
166
|
{v}
|