@dashforge/tw 0.9.1-beta → 0.11.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 (47) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/index.esm.js +1330 -405
  3. package/dist/src/components/DateRangePicker/DateRangePicker.d.ts +13 -0
  4. package/dist/src/components/DateRangePicker/DateRangePicker.d.ts.map +1 -0
  5. package/dist/src/components/DateRangePicker/dateRangePicker.types.d.ts +85 -0
  6. package/dist/src/components/DateRangePicker/dateRangePicker.types.d.ts.map +1 -0
  7. package/dist/src/components/DateRangePicker/dateRangePicker.variants.d.ts +183 -0
  8. package/dist/src/components/DateRangePicker/dateRangePicker.variants.d.ts.map +1 -0
  9. package/dist/src/components/DateTimePicker/DateTimePicker.d.ts +7 -43
  10. package/dist/src/components/DateTimePicker/DateTimePicker.d.ts.map +1 -1
  11. package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts +50 -55
  12. package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts.map +1 -1
  13. package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts +36 -94
  14. package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts.map +1 -1
  15. package/dist/src/components/Tabs/Tabs.d.ts +8 -4
  16. package/dist/src/components/Tabs/Tabs.d.ts.map +1 -1
  17. package/dist/src/components/Tabs/tabs.types.d.ts +8 -2
  18. package/dist/src/components/Tabs/tabs.types.d.ts.map +1 -1
  19. package/dist/src/components/Tabs/useTabs.d.ts +55 -0
  20. package/dist/src/components/Tabs/useTabs.d.ts.map +1 -0
  21. package/dist/src/components/TimePicker/TimePicker.d.ts +12 -0
  22. package/dist/src/components/TimePicker/TimePicker.d.ts.map +1 -0
  23. package/dist/src/components/TimePicker/timePicker.types.d.ts +80 -0
  24. package/dist/src/components/TimePicker/timePicker.types.d.ts.map +1 -0
  25. package/dist/src/components/TimePicker/timePicker.variants.d.ts +103 -0
  26. package/dist/src/components/TimePicker/timePicker.variants.d.ts.map +1 -0
  27. package/dist/src/index.d.ts +10 -4
  28. package/dist/src/index.d.ts.map +1 -1
  29. package/package.json +3 -4
  30. package/src/components/DateRangePicker/DateRangePicker.test.tsx +92 -0
  31. package/src/components/DateRangePicker/DateRangePicker.tsx +510 -0
  32. package/src/components/DateRangePicker/dateRangePicker.types.ts +74 -0
  33. package/src/components/DateRangePicker/dateRangePicker.variants.ts +101 -0
  34. package/src/components/DateTimePicker/DateTimePicker.test.tsx +60 -186
  35. package/src/components/DateTimePicker/DateTimePicker.tsx +209 -181
  36. package/src/components/DateTimePicker/dateTimePicker.types.ts +50 -54
  37. package/src/components/DateTimePicker/dateTimePicker.variants.ts +22 -51
  38. package/src/components/Tabs/Tabs.test.tsx +85 -14
  39. package/src/components/Tabs/Tabs.tsx +35 -24
  40. package/src/components/Tabs/tabs.types.ts +8 -2
  41. package/src/components/Tabs/useTabs.ts +167 -0
  42. package/src/components/TimePicker/TimePicker.test.tsx +65 -0
  43. package/src/components/TimePicker/TimePicker.tsx +274 -0
  44. package/src/components/TimePicker/timePicker.types.ts +69 -0
  45. package/src/components/TimePicker/timePicker.variants.ts +53 -0
  46. package/src/index.ts +28 -16
  47. package/src/components/DateTimePicker/DateTimePicker.perf.test.tsx +0 -153
@@ -0,0 +1,92 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeAll } from 'vitest';
3
+ import { render, screen, fireEvent } from '@testing-library/react';
4
+
5
+ import { DateRangePicker } from './DateRangePicker.js';
6
+
7
+ beforeAll(() => {
8
+ // Radix Popover (Floating UI) needs ResizeObserver — jsdom lacks it.
9
+ if (!('ResizeObserver' in globalThis)) {
10
+ class ResizeObserverStub {
11
+ observe(): void {
12
+ /* no-op */
13
+ }
14
+ unobserve(): void {
15
+ /* no-op */
16
+ }
17
+ disconnect(): void {
18
+ /* no-op */
19
+ }
20
+ }
21
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver =
22
+ ResizeObserverStub;
23
+ }
24
+ });
25
+
26
+ describe('DateRangePicker', () => {
27
+ it('renders a labelled trigger with the placeholder', () => {
28
+ render(
29
+ <DateRangePicker name="period" label="Period" placeholder="Pick a range" />,
30
+ );
31
+ expect(screen.queryByText('Period')).not.toBeNull();
32
+ expect(screen.getByRole('button').textContent).toContain('Pick a range');
33
+ });
34
+
35
+ it('shows a localized controlled range', () => {
36
+ render(
37
+ <DateRangePicker
38
+ name="p"
39
+ value={{ start: '2026-05-10', end: '2026-05-15' }}
40
+ />,
41
+ );
42
+ expect(screen.getByRole('button').textContent).toContain(
43
+ 'May 10, 2026 – May 15, 2026',
44
+ );
45
+ });
46
+
47
+ it('opens a dual-month popover from the trigger', () => {
48
+ render(
49
+ <DateRangePicker name="p" defaultValue={{ start: '2026-05-10', end: null }} />,
50
+ );
51
+ expect(screen.queryByRole('dialog')).toBeNull();
52
+ fireEvent.click(screen.getByRole('button', { name: /Select|May/ }));
53
+ expect(screen.queryByRole('dialog')).not.toBeNull();
54
+ expect(screen.getAllByRole('grid')).toHaveLength(2);
55
+ });
56
+
57
+ it('completes the range on the second click and closes the popover', () => {
58
+ const onChange = vi.fn();
59
+ render(
60
+ <DateRangePicker
61
+ name="p"
62
+ defaultValue={{ start: '2026-05-10', end: null }}
63
+ onChange={onChange}
64
+ />,
65
+ );
66
+ fireEvent.click(screen.getByRole('button', { name: /May 10/ }));
67
+ fireEvent.click(screen.getByRole('button', { name: /May 18, 2026/ }));
68
+ expect(onChange).toHaveBeenCalledWith({ start: '2026-05-10', end: '2026-05-18' });
69
+ expect(screen.queryByRole('dialog')).toBeNull();
70
+ });
71
+
72
+ it('restarts the range on a fresh click and keeps the popover open', () => {
73
+ const onChange = vi.fn();
74
+ render(
75
+ <DateRangePicker
76
+ name="p"
77
+ defaultValue={{ start: '2026-05-20', end: '2026-05-25' }}
78
+ onChange={onChange}
79
+ />,
80
+ );
81
+ fireEvent.click(screen.getByRole('button', { name: /May 20/ }));
82
+ fireEvent.click(screen.getByRole('button', { name: /May 12, 2026/ }));
83
+ expect(onChange).toHaveBeenCalledWith({ start: '2026-05-12', end: null });
84
+ expect(screen.queryByRole('dialog')).not.toBeNull();
85
+ });
86
+
87
+ it('does not open the popover when disabled', () => {
88
+ render(<DateRangePicker name="p" disabled />);
89
+ fireEvent.click(screen.getByRole('button'));
90
+ expect(screen.queryByRole('dialog')).toBeNull();
91
+ });
92
+ });
@@ -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
+ }