@dashforge/tw 0.9.0-beta → 0.10.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/index.esm.js +1634 -256
  3. package/dist/src/components/Calendar/Calendar.d.ts +14 -0
  4. package/dist/src/components/Calendar/Calendar.d.ts.map +1 -0
  5. package/dist/src/components/Calendar/calendar.types.d.ts +72 -0
  6. package/dist/src/components/Calendar/calendar.types.d.ts.map +1 -0
  7. package/dist/src/components/Calendar/calendar.variants.d.ts +99 -0
  8. package/dist/src/components/Calendar/calendar.variants.d.ts.map +1 -0
  9. package/dist/src/components/DatePicker/DatePicker.d.ts +14 -0
  10. package/dist/src/components/DatePicker/DatePicker.d.ts.map +1 -0
  11. package/dist/src/components/DatePicker/datePicker.types.d.ts +88 -0
  12. package/dist/src/components/DatePicker/datePicker.types.d.ts.map +1 -0
  13. package/dist/src/components/DatePicker/datePicker.variants.d.ts +97 -0
  14. package/dist/src/components/DatePicker/datePicker.variants.d.ts.map +1 -0
  15. package/dist/src/components/DateRangePicker/DateRangePicker.d.ts +13 -0
  16. package/dist/src/components/DateRangePicker/DateRangePicker.d.ts.map +1 -0
  17. package/dist/src/components/DateRangePicker/dateRangePicker.types.d.ts +85 -0
  18. package/dist/src/components/DateRangePicker/dateRangePicker.types.d.ts.map +1 -0
  19. package/dist/src/components/DateRangePicker/dateRangePicker.variants.d.ts +183 -0
  20. package/dist/src/components/DateRangePicker/dateRangePicker.variants.d.ts.map +1 -0
  21. package/dist/src/components/DateTimePicker/DateTimePicker.d.ts +7 -43
  22. package/dist/src/components/DateTimePicker/DateTimePicker.d.ts.map +1 -1
  23. package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts +50 -55
  24. package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts.map +1 -1
  25. package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts +36 -94
  26. package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts.map +1 -1
  27. package/dist/src/components/TimePicker/TimePicker.d.ts +12 -0
  28. package/dist/src/components/TimePicker/TimePicker.d.ts.map +1 -0
  29. package/dist/src/components/TimePicker/timePicker.types.d.ts +80 -0
  30. package/dist/src/components/TimePicker/timePicker.types.d.ts.map +1 -0
  31. package/dist/src/components/TimePicker/timePicker.variants.d.ts +103 -0
  32. package/dist/src/components/TimePicker/timePicker.variants.d.ts.map +1 -0
  33. package/dist/src/index.d.ts +15 -3
  34. package/dist/src/index.d.ts.map +1 -1
  35. package/package.json +4 -3
  36. package/src/components/Calendar/Calendar.test.tsx +75 -0
  37. package/src/components/Calendar/Calendar.tsx +264 -0
  38. package/src/components/Calendar/calendar.types.ts +65 -0
  39. package/src/components/Calendar/calendar.variants.ts +64 -0
  40. package/src/components/DatePicker/DatePicker.test.tsx +76 -0
  41. package/src/components/DatePicker/DatePicker.tsx +305 -0
  42. package/src/components/DatePicker/datePicker.types.ts +77 -0
  43. package/src/components/DatePicker/datePicker.variants.ts +50 -0
  44. package/src/components/DateRangePicker/DateRangePicker.test.tsx +92 -0
  45. package/src/components/DateRangePicker/DateRangePicker.tsx +510 -0
  46. package/src/components/DateRangePicker/dateRangePicker.types.ts +74 -0
  47. package/src/components/DateRangePicker/dateRangePicker.variants.ts +101 -0
  48. package/src/components/DateTimePicker/DateTimePicker.test.tsx +60 -186
  49. package/src/components/DateTimePicker/DateTimePicker.tsx +209 -181
  50. package/src/components/DateTimePicker/dateTimePicker.types.ts +50 -54
  51. package/src/components/DateTimePicker/dateTimePicker.variants.ts +22 -51
  52. package/src/components/TimePicker/TimePicker.test.tsx +65 -0
  53. package/src/components/TimePicker/TimePicker.tsx +274 -0
  54. package/src/components/TimePicker/timePicker.types.ts +69 -0
  55. package/src/components/TimePicker/timePicker.variants.ts +53 -0
  56. package/src/index.ts +38 -6
  57. package/tsconfig.lib.json +7 -4
  58. package/src/components/DateTimePicker/DateTimePicker.perf.test.tsx +0 -153
@@ -0,0 +1,510 @@
1
+ import {
2
+ useCallback,
3
+ useContext,
4
+ useEffect,
5
+ useId,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
9
+ import type { KeyboardEvent } from 'react';
10
+ import { DashFormContext, useEngineVisibility } from '@dashforge/ui-core';
11
+ import type { DashFormBridge, FieldRegistration } from '@dashforge/ui-core';
12
+ import { useDashFieldMeta } from '@dashforge/forms';
13
+ import {
14
+ formatLongDate,
15
+ parseISODate,
16
+ resolveCalendarKey,
17
+ useDateRange,
18
+ } from '@dashforge/calendar-core';
19
+ import type { DateRange, ISODate, WeekDay } from '@dashforge/calendar-core';
20
+ import { cn } from '../../utils/cn.js';
21
+ import { useAccessState } from '../../hooks/useAccessState.js';
22
+ import { resolveValidationState } from '../_shared/resolveValidationState.js';
23
+ import { Popover } from '../Popover/Popover.js';
24
+ import {
25
+ dateRangeDayVariants,
26
+ dateRangePickerVariants,
27
+ } from './dateRangePicker.variants.js';
28
+ import type { DateRangePickerProps } from './dateRangePicker.types.js';
29
+
30
+ const EMPTY_RANGE: DateRange = { start: null, end: null };
31
+
32
+ function CalendarIcon() {
33
+ return (
34
+ <svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" aria-hidden="true">
35
+ <rect
36
+ x="2.5"
37
+ y="3.5"
38
+ width="11"
39
+ height="10"
40
+ rx="1.5"
41
+ stroke="currentColor"
42
+ strokeWidth="1.5"
43
+ />
44
+ <path
45
+ d="M2.5 6.5h11M5.5 2v3M10.5 2v3"
46
+ stroke="currentColor"
47
+ strokeWidth="1.5"
48
+ strokeLinecap="round"
49
+ />
50
+ </svg>
51
+ );
52
+ }
53
+
54
+ function ChevronLeftIcon() {
55
+ return (
56
+ <svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" aria-hidden="true">
57
+ <path
58
+ d="M10 4 6 8l4 4"
59
+ stroke="currentColor"
60
+ strokeWidth="1.5"
61
+ strokeLinecap="round"
62
+ strokeLinejoin="round"
63
+ />
64
+ </svg>
65
+ );
66
+ }
67
+
68
+ function ChevronRightIcon() {
69
+ return (
70
+ <svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" aria-hidden="true">
71
+ <path
72
+ d="m6 4 4 4-4 4"
73
+ stroke="currentColor"
74
+ strokeWidth="1.5"
75
+ strokeLinecap="round"
76
+ strokeLinejoin="round"
77
+ />
78
+ </svg>
79
+ );
80
+ }
81
+
82
+ /** Formats a stored ISO date for the trigger display (medium style). */
83
+ function formatEndpoint(iso: ISODate | null, locale: string): string {
84
+ if (iso === null || iso === '') {
85
+ return '';
86
+ }
87
+ const parts = parseISODate(iso);
88
+ if (parts === null) {
89
+ return iso;
90
+ }
91
+ return new Intl.DateTimeFormat(locale, {
92
+ dateStyle: 'medium',
93
+ timeZone: 'UTC',
94
+ }).format(Date.UTC(parts.year, parts.month - 1, parts.day));
95
+ }
96
+
97
+ function formatRange(range: DateRange, locale: string): string {
98
+ const start = formatEndpoint(range.start, locale);
99
+ const end = formatEndpoint(range.end, locale);
100
+ if (start === '') {
101
+ return '';
102
+ }
103
+ return end === '' ? start : `${start} – ${end}`;
104
+ }
105
+
106
+ interface RangeCalendarProps {
107
+ value: DateRange;
108
+ onChange: (range: DateRange) => void;
109
+ minDate?: ISODate;
110
+ maxDate?: ISODate;
111
+ disabledDates?: readonly ISODate[];
112
+ isDateDisabled?: (date: ISODate) => boolean;
113
+ weekStartDay?: WeekDay;
114
+ locale?: string;
115
+ }
116
+
117
+ /** The dual-month range grid rendered inside the popover. */
118
+ function RangeCalendar(props: RangeCalendarProps) {
119
+ const {
120
+ value,
121
+ onChange,
122
+ minDate,
123
+ maxDate,
124
+ disabledDates,
125
+ isDateDisabled,
126
+ weekStartDay,
127
+ locale,
128
+ } = props;
129
+
130
+ const range = useDateRange({
131
+ value,
132
+ onChange,
133
+ monthCount: 2,
134
+ ...(minDate !== undefined && { minDate }),
135
+ ...(maxDate !== undefined && { maxDate }),
136
+ ...(disabledDates !== undefined && { disabledDates }),
137
+ ...(isDateDisabled !== undefined && { isDateDisabled }),
138
+ ...(weekStartDay !== undefined && { weekStartDay }),
139
+ ...(locale !== undefined && { locale }),
140
+ });
141
+
142
+ const focusedCellRef = useRef<HTMLButtonElement | null>(null);
143
+ const shouldFocusRef = useRef(false);
144
+ useEffect(() => {
145
+ if (shouldFocusRef.current && focusedCellRef.current) {
146
+ focusedCellRef.current.focus({ preventScroll: true });
147
+ shouldFocusRef.current = false;
148
+ }
149
+ }, [range.focusedDate]);
150
+
151
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
152
+ const action = resolveCalendarKey(event.key);
153
+ if (action === null) {
154
+ return;
155
+ }
156
+ event.preventDefault();
157
+ shouldFocusRef.current = true;
158
+ switch (action) {
159
+ case 'select':
160
+ range.selectDate(range.focusedDate);
161
+ break;
162
+ case 'monthForward':
163
+ range.goToNextMonth();
164
+ break;
165
+ case 'monthBackward':
166
+ range.goToPreviousMonth();
167
+ break;
168
+ default:
169
+ range.navigate(action);
170
+ }
171
+ };
172
+
173
+ const v = dateRangePickerVariants();
174
+ const resolvedLocale = locale ?? 'en-US';
175
+ const lastIndex = range.months.length - 1;
176
+
177
+ return (
178
+ <div
179
+ className={v.panel()}
180
+ onMouseLeave={() => {
181
+ range.hoverDate(null);
182
+ }}
183
+ >
184
+ {range.months.map((monthView, monthIndex) => (
185
+ <div
186
+ key={`${String(monthView.year)}-${String(monthView.month)}`}
187
+ className={v.month()}
188
+ >
189
+ <div className={v.header()}>
190
+ {monthIndex === 0 ? (
191
+ <button
192
+ type="button"
193
+ className={v.navButton()}
194
+ aria-label="Previous month"
195
+ onClick={() => {
196
+ range.goToPreviousMonth();
197
+ }}
198
+ >
199
+ <ChevronLeftIcon />
200
+ </button>
201
+ ) : (
202
+ <span className={v.navSpacer()} />
203
+ )}
204
+ <span aria-live="polite" className={v.monthLabel()}>
205
+ {monthView.monthLabel}
206
+ </span>
207
+ {monthIndex === lastIndex ? (
208
+ <button
209
+ type="button"
210
+ className={v.navButton()}
211
+ aria-label="Next month"
212
+ onClick={() => {
213
+ range.goToNextMonth();
214
+ }}
215
+ >
216
+ <ChevronRightIcon />
217
+ </button>
218
+ ) : (
219
+ <span className={v.navSpacer()} />
220
+ )}
221
+ </div>
222
+
223
+ <div
224
+ role="grid"
225
+ aria-label="Choose date range"
226
+ onKeyDown={handleKeyDown}
227
+ className={v.grid()}
228
+ >
229
+ <div role="row" className={v.weekdayRow()}>
230
+ {range.weekdayNames.map((dayName, index) => (
231
+ <span
232
+ key={`weekday-${String(index)}`}
233
+ role="columnheader"
234
+ className={v.weekday()}
235
+ >
236
+ {dayName}
237
+ </span>
238
+ ))}
239
+ </div>
240
+
241
+ {monthView.weeks.map((week) => (
242
+ <div key={week.key} role="row" className={v.weekRow()}>
243
+ {week.days.map((day) => {
244
+ const isActiveCell = day.iso === range.focusedDate;
245
+ const endpoint = day.isRangeStart || day.isRangeEnd;
246
+ const inBand = day.isInRange || day.isRangePreview;
247
+ return (
248
+ <span key={day.iso} role="gridcell" className="flex">
249
+ <button
250
+ type="button"
251
+ ref={isActiveCell ? focusedCellRef : null}
252
+ tabIndex={isActiveCell ? 0 : -1}
253
+ aria-label={formatLongDate(
254
+ { year: day.year, month: day.month, day: day.day },
255
+ resolvedLocale,
256
+ )}
257
+ aria-current={day.isToday ? 'date' : undefined}
258
+ aria-pressed={endpoint}
259
+ aria-disabled={day.isDisabled}
260
+ onClick={() => {
261
+ range.selectDate(day.iso);
262
+ }}
263
+ onMouseEnter={() => {
264
+ range.hoverDate(day.iso);
265
+ }}
266
+ className={dateRangeDayVariants({
267
+ inBand,
268
+ endpoint,
269
+ siblingMonth: day.isSiblingMonth,
270
+ today: day.isToday,
271
+ disabled: day.isDisabled,
272
+ })}
273
+ >
274
+ {day.day}
275
+ </button>
276
+ </span>
277
+ );
278
+ })}
279
+ </div>
280
+ ))}
281
+ </div>
282
+ </div>
283
+ ))}
284
+ </div>
285
+ );
286
+ }
287
+
288
+ /**
289
+ * Dashforge TW `<DateRangePicker>` — a form-bound start/end date field.
290
+ *
291
+ * A read-only trigger button paired with a dual-month range calendar popover
292
+ * (Radix Popover), built on the headless `useDateRange` engine. Integrates
293
+ * with the Dashforge form bridge + RBAC.
294
+ *
295
+ * Storage contract: a `{ start, end }` pair of ISO `YYYY-MM-DD` dates —
296
+ * shared with the MUI `@dashforge/ui` `DateRangePicker`.
297
+ */
298
+ export function DateRangePicker(props: DateRangePickerProps) {
299
+ const {
300
+ name,
301
+ rules,
302
+ label,
303
+ helperText,
304
+ error,
305
+ required,
306
+ disabled,
307
+ placeholder,
308
+ layout = 'stacked',
309
+ visibleWhen,
310
+ access,
311
+ value: explicitValue,
312
+ defaultValue,
313
+ onChange,
314
+ minDate,
315
+ maxDate,
316
+ disabledDates,
317
+ isDateDisabled,
318
+ weekStartDay,
319
+ locale,
320
+ fullWidth,
321
+ sx,
322
+ slotProps,
323
+ testId,
324
+ } = props;
325
+
326
+ const bridge = useContext(DashFormContext) as DashFormBridge | null;
327
+ const isVisible = useEngineVisibility(bridge?.engine, visibleWhen);
328
+ const fieldMeta = useDashFieldMeta(name);
329
+ const accessState = useAccessState(access);
330
+
331
+ const fieldId = useId();
332
+ const helperId = `${fieldId}-help`;
333
+
334
+ const [isOpen, setIsOpen] = useState(false);
335
+ const [internalValue, setInternalValue] = useState<DateRange>(
336
+ defaultValue ?? EMPTY_RANGE,
337
+ );
338
+
339
+ const unregisterRef = useRef({ bridge, name });
340
+ unregisterRef.current = { bridge, name };
341
+ const isMountedRef = useRef(false);
342
+ useEffect(() => {
343
+ isMountedRef.current = true;
344
+ return () => {
345
+ isMountedRef.current = false;
346
+ const { bridge: cap, name: capName } = unregisterRef.current;
347
+ queueMicrotask(() => {
348
+ if (!isMountedRef.current) cap?.unregister?.(capName);
349
+ });
350
+ };
351
+ }, []);
352
+
353
+ const effectiveDisabled = Boolean(disabled) || accessState.disabled;
354
+ const isInteractive = !effectiveDisabled && !accessState.readonly;
355
+ const isFormMode = Boolean(bridge?.register);
356
+
357
+ let resolvedError = error;
358
+ let resolvedHelperText: typeof helperText = helperText;
359
+ let registration: FieldRegistration | null = null;
360
+ let resolvedValue: DateRange;
361
+
362
+ if (isFormMode && bridge) {
363
+ registration = bridge.register(name, rules);
364
+ const validation = resolveValidationState(name, bridge, error, helperText);
365
+ resolvedError = validation.error;
366
+ resolvedHelperText = validation.helperText;
367
+ if (explicitValue !== undefined) {
368
+ resolvedValue = explicitValue;
369
+ } else {
370
+ const bv = fieldMeta.value as DateRange | null | undefined;
371
+ resolvedValue = bv ?? EMPTY_RANGE;
372
+ }
373
+ } else if (explicitValue !== undefined) {
374
+ resolvedValue = explicitValue;
375
+ } else {
376
+ resolvedValue = internalValue;
377
+ }
378
+
379
+ const registrationRefFn = registration?.ref;
380
+ const triggerRef = useCallback(
381
+ (instance: HTMLButtonElement | null) => {
382
+ if (typeof registrationRefFn === 'function') {
383
+ registrationRefFn(instance);
384
+ }
385
+ },
386
+ [registrationRefFn],
387
+ );
388
+
389
+ if (!isVisible) return null;
390
+ if (!accessState.visible) return null;
391
+
392
+ const v = dateRangePickerVariants({ layout, error: resolvedError, fullWidth });
393
+ const resolvedLocale = locale ?? 'en-US';
394
+ const displayValue = formatRange(resolvedValue, resolvedLocale);
395
+
396
+ const commitValue = (next: DateRange) => {
397
+ if (isFormMode && bridge) {
398
+ bridge.setValue?.(name, next);
399
+ void registration?.onChange?.({
400
+ target: { name, value: next },
401
+ type: 'change',
402
+ });
403
+ } else if (explicitValue === undefined) {
404
+ setInternalValue(next);
405
+ }
406
+ onChange?.(next);
407
+ };
408
+
409
+ const markTouched = () => {
410
+ if (isFormMode && bridge) {
411
+ const committed = bridge.getValue(name);
412
+ void registration?.onBlur?.({
413
+ target: { name, value: committed },
414
+ type: 'blur',
415
+ });
416
+ }
417
+ };
418
+
419
+ const handleOpenChange = (next: boolean) => {
420
+ if (next && !isInteractive) {
421
+ return;
422
+ }
423
+ setIsOpen(next);
424
+ if (!next) {
425
+ markTouched();
426
+ }
427
+ };
428
+
429
+ const handleRangeChange = (next: DateRange) => {
430
+ commitValue(next);
431
+ if (next.start !== null && next.end !== null) {
432
+ setIsOpen(false);
433
+ markTouched();
434
+ }
435
+ };
436
+
437
+ return (
438
+ <div
439
+ data-testid={testId}
440
+ className={cn(v.root(), sx, slotProps?.root?.className)}
441
+ >
442
+ {label && (
443
+ <label
444
+ htmlFor={fieldId}
445
+ className={cn(v.label(), slotProps?.label?.className)}
446
+ >
447
+ {label}
448
+ {required && (
449
+ <span
450
+ aria-hidden="true"
451
+ className={cn(v.requiredMark(), slotProps?.requiredMark?.className)}
452
+ >
453
+ *
454
+ </span>
455
+ )}
456
+ </label>
457
+ )}
458
+
459
+ <Popover
460
+ open={isOpen}
461
+ onOpenChange={handleOpenChange}
462
+ side="bottom"
463
+ align="start"
464
+ content={
465
+ <RangeCalendar
466
+ value={resolvedValue}
467
+ onChange={handleRangeChange}
468
+ minDate={minDate}
469
+ maxDate={maxDate}
470
+ disabledDates={disabledDates}
471
+ isDateDisabled={isDateDisabled}
472
+ weekStartDay={weekStartDay}
473
+ locale={locale}
474
+ />
475
+ }
476
+ >
477
+ <button
478
+ type="button"
479
+ id={fieldId}
480
+ ref={triggerRef}
481
+ disabled={effectiveDisabled}
482
+ aria-invalid={resolvedError ? true : undefined}
483
+ aria-describedby={resolvedHelperText ? helperId : undefined}
484
+ className={cn(v.trigger(), slotProps?.trigger?.className)}
485
+ >
486
+ <span className={displayValue ? v.value() : v.placeholder()}>
487
+ {displayValue || placeholder || 'Select a date range'}
488
+ </span>
489
+ <span className={v.icon()}>
490
+ <CalendarIcon />
491
+ </span>
492
+ </button>
493
+ </Popover>
494
+
495
+ {resolvedHelperText && (
496
+ <p
497
+ id={helperId}
498
+ className={cn(
499
+ resolvedError ? v.errorText() : v.helperText(),
500
+ resolvedError
501
+ ? slotProps?.errorText?.className
502
+ : slotProps?.helperText?.className,
503
+ )}
504
+ >
505
+ {resolvedHelperText}
506
+ </p>
507
+ )}
508
+ </div>
509
+ );
510
+ }
@@ -0,0 +1,74 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { Engine } from '@dashforge/ui-core';
3
+ import type { AccessRequirement } from '@dashforge/rbac';
4
+ import type { DateRange, ISODate, WeekDay } from '@dashforge/calendar-core';
5
+
6
+ /** Per-slot `className` overrides for `<DateRangePicker>`. */
7
+ export interface DateRangePickerSlotProps {
8
+ root?: { className?: string };
9
+ label?: { className?: string };
10
+ requiredMark?: { className?: string };
11
+ trigger?: { className?: string };
12
+ helperText?: { className?: string };
13
+ errorText?: { className?: string };
14
+ }
15
+
16
+ /**
17
+ * Props for the `<DateRangePicker>` form field (Tailwind skin).
18
+ *
19
+ * A read-only trigger paired with a dual-month range calendar popover. The
20
+ * stored value is a `{ start, end }` pair of plain ISO calendar dates
21
+ * (`YYYY-MM-DD`); either side may be `null`. No time, no timezone.
22
+ *
23
+ * The prop surface mirrors the MUI `@dashforge/ui` `DateRangePicker`.
24
+ */
25
+ export interface DateRangePickerProps {
26
+ /** Field name — the bridge registration key. Required. */
27
+ name: string;
28
+ /** Validation rules forwarded to the form bridge. */
29
+ rules?: unknown;
30
+ /** Field label. */
31
+ label?: ReactNode;
32
+ /** Helper text below the control (overrides a bridge error message). */
33
+ helperText?: ReactNode;
34
+ /** Explicit error state (overrides the bridge's auto error). */
35
+ error?: boolean;
36
+ /** Marks the field required (adds the label asterisk). */
37
+ required?: boolean;
38
+ /** Disables the field. */
39
+ disabled?: boolean;
40
+ /** Placeholder shown when no range is selected. */
41
+ placeholder?: string;
42
+ /** Label/control layout. */
43
+ layout?: 'stacked' | 'inline';
44
+ /** Reactive visibility predicate evaluated against the form engine. */
45
+ visibleWhen?: (engine: Engine) => boolean;
46
+ /** RBAC access requirement. */
47
+ access?: AccessRequirement;
48
+ /** Controlled value — a `{ start, end }` pair. */
49
+ value?: DateRange;
50
+ /** Uncontrolled initial value. */
51
+ defaultValue?: DateRange;
52
+ /** Fired with the new range (partial on the first click, complete on the second). */
53
+ onChange?: (value: DateRange) => void;
54
+ /** Earliest selectable date (inclusive). */
55
+ minDate?: ISODate;
56
+ /** Latest selectable date (inclusive). */
57
+ maxDate?: ISODate;
58
+ /** Explicit list of disabled dates. */
59
+ disabledDates?: readonly ISODate[];
60
+ /** Predicate marking arbitrary dates disabled. */
61
+ isDateDisabled?: (date: ISODate) => boolean;
62
+ /** Weekday of the calendar's first column (`0` = Sunday). */
63
+ weekStartDay?: WeekDay;
64
+ /** BCP-47 locale for the calendar and the display format. */
65
+ locale?: string;
66
+ /** Stretches the field to its container width. */
67
+ fullWidth?: boolean;
68
+ /** Root-level Tailwind class override. */
69
+ sx?: string;
70
+ /** Per-slot `className` overrides. */
71
+ slotProps?: DateRangePickerSlotProps;
72
+ /** Test id applied to the field root. */
73
+ testId?: string;
74
+ }
@@ -0,0 +1,101 @@
1
+ import { tv } from 'tailwind-variants';
2
+
3
+ /**
4
+ * Slots for `<DateRangePicker>` — a bridge-integrated start/end date field
5
+ * whose trigger is a `<button>` styled as an input, opening a dual-month
6
+ * range calendar popover.
7
+ *
8
+ * Theme identity: bare neutral / brand token-backed classes only — no
9
+ * `dark:` variants on the neutral palette (the preset CSS-var swap
10
+ * auto-inverts). See `_shared/themeIdentity.test.ts`.
11
+ */
12
+ export const dateRangePickerVariants = tv({
13
+ slots: {
14
+ root: 'flex flex-col',
15
+ label: 'block text-sm font-medium text-neutral-900 mb-1',
16
+ requiredMark: 'text-danger-500 ml-0.5',
17
+ trigger:
18
+ 'flex items-center gap-2 h-10 min-w-[15rem] rounded-md border border-neutral-300 bg-neutral-50 px-3 text-base text-neutral-900 cursor-pointer transition-colors hover:border-neutral-400 focus-visible:outline-none focus-visible:border-primary-500 focus-visible:ring-2 focus-visible:ring-primary-500/30 disabled:cursor-not-allowed disabled:opacity-60 disabled:bg-neutral-200',
19
+ value: 'flex-1 truncate text-left',
20
+ placeholder: 'flex-1 truncate text-left text-neutral-400',
21
+ icon: 'shrink-0 text-[1rem] text-neutral-500',
22
+ panel: 'flex gap-3 p-3 select-none',
23
+ month: 'flex flex-col gap-2',
24
+ header: 'flex items-center justify-between gap-1',
25
+ navButton:
26
+ 'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[1rem] text-neutral-600 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
27
+ navSpacer: 'h-7 w-7 shrink-0',
28
+ monthLabel: 'flex-1 text-center text-sm font-semibold text-neutral-900',
29
+ grid: 'flex flex-col gap-1',
30
+ weekdayRow: 'grid grid-cols-7 gap-1',
31
+ weekday:
32
+ 'flex h-8 items-center justify-center text-xs font-semibold text-neutral-500',
33
+ weekRow: 'grid grid-cols-7 gap-1',
34
+ helperText: 'mt-1 text-sm text-neutral-600',
35
+ errorText: 'mt-1 text-sm text-danger-600',
36
+ },
37
+ variants: {
38
+ layout: {
39
+ stacked: {},
40
+ inline: {
41
+ root: 'flex-row items-start gap-3',
42
+ label: 'mb-0 pt-2.5 whitespace-nowrap shrink-0',
43
+ },
44
+ },
45
+ error: {
46
+ true: {
47
+ trigger:
48
+ 'border-danger-500 hover:border-danger-500 focus-visible:border-danger-500 focus-visible:ring-danger-500/30',
49
+ },
50
+ },
51
+ fullWidth: {
52
+ true: {
53
+ root: 'w-full',
54
+ trigger: 'w-full min-w-0',
55
+ },
56
+ },
57
+ },
58
+ defaultVariants: {
59
+ layout: 'stacked',
60
+ error: false,
61
+ fullWidth: false,
62
+ },
63
+ });
64
+
65
+ /**
66
+ * Per-day-cell recipe for the range grid. `endpoint` (a committed start/end)
67
+ * is the solid fill; `inBand` (inside the committed or previewed range) is
68
+ * the light tint. Declared `inBand` before `endpoint` so an endpoint cell —
69
+ * which is also in-band — resolves to the solid fill.
70
+ */
71
+ export const dateRangeDayVariants = tv({
72
+ base: 'flex h-9 w-9 items-center justify-center rounded-md text-sm text-neutral-900 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500',
73
+ variants: {
74
+ inBand: {
75
+ true: 'bg-primary-100 hover:bg-primary-100',
76
+ },
77
+ endpoint: {
78
+ true: 'bg-primary-600 text-white hover:bg-primary-600',
79
+ },
80
+ siblingMonth: {
81
+ true: 'text-neutral-400',
82
+ },
83
+ today: {
84
+ true: 'font-bold ring-1 ring-inset ring-primary-400',
85
+ },
86
+ disabled: {
87
+ true: 'cursor-default text-neutral-300 opacity-60 hover:bg-transparent',
88
+ },
89
+ },
90
+ compoundVariants: [
91
+ { endpoint: true, today: true, class: 'ring-0' },
92
+ { endpoint: true, siblingMonth: true, class: 'text-white' },
93
+ ],
94
+ defaultVariants: {
95
+ inBand: false,
96
+ endpoint: false,
97
+ siblingMonth: false,
98
+ today: false,
99
+ disabled: false,
100
+ },
101
+ });