@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.
@@ -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 bg-overlay pt-2 sm:flex-row sm:justify-end",
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,
@@ -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
- // width of the trigger button defaults to a compact 110px
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
- "inline-flex h-8 items-center justify-between gap-2 rounded-field border border-border bg-field-background px-2.5 text-xs font-medium tabular-nums transition-[border-color,box-shadow,background-color] duration-200 ease-smooth hover:bg-field-background/90 focus-visible:border-focus-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50",
92
- triggerClassName ?? "w-[110px]",
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
- ? "bg-primary text-primary-foreground"
156
- : "text-foreground/80 hover:bg-surface-tertiary/50",
162
+ ? pickerSelectedClass
163
+ : cn("text-foreground/80", pickerItemHoverClass),
157
164
  )}
158
165
  >
159
166
  {v}