@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.
- package/CHANGELOG.md +53 -0
- package/dist/index.esm.js +1330 -405
- package/dist/src/components/DateRangePicker/DateRangePicker.d.ts +13 -0
- package/dist/src/components/DateRangePicker/DateRangePicker.d.ts.map +1 -0
- package/dist/src/components/DateRangePicker/dateRangePicker.types.d.ts +85 -0
- package/dist/src/components/DateRangePicker/dateRangePicker.types.d.ts.map +1 -0
- package/dist/src/components/DateRangePicker/dateRangePicker.variants.d.ts +183 -0
- package/dist/src/components/DateRangePicker/dateRangePicker.variants.d.ts.map +1 -0
- package/dist/src/components/DateTimePicker/DateTimePicker.d.ts +7 -43
- package/dist/src/components/DateTimePicker/DateTimePicker.d.ts.map +1 -1
- package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts +50 -55
- package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts.map +1 -1
- package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts +36 -94
- package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts.map +1 -1
- package/dist/src/components/Tabs/Tabs.d.ts +8 -4
- package/dist/src/components/Tabs/Tabs.d.ts.map +1 -1
- package/dist/src/components/Tabs/tabs.types.d.ts +8 -2
- package/dist/src/components/Tabs/tabs.types.d.ts.map +1 -1
- package/dist/src/components/Tabs/useTabs.d.ts +55 -0
- package/dist/src/components/Tabs/useTabs.d.ts.map +1 -0
- package/dist/src/components/TimePicker/TimePicker.d.ts +12 -0
- package/dist/src/components/TimePicker/TimePicker.d.ts.map +1 -0
- package/dist/src/components/TimePicker/timePicker.types.d.ts +80 -0
- package/dist/src/components/TimePicker/timePicker.types.d.ts.map +1 -0
- package/dist/src/components/TimePicker/timePicker.variants.d.ts +103 -0
- package/dist/src/components/TimePicker/timePicker.variants.d.ts.map +1 -0
- package/dist/src/index.d.ts +10 -4
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +3 -4
- package/src/components/DateRangePicker/DateRangePicker.test.tsx +92 -0
- package/src/components/DateRangePicker/DateRangePicker.tsx +510 -0
- package/src/components/DateRangePicker/dateRangePicker.types.ts +74 -0
- package/src/components/DateRangePicker/dateRangePicker.variants.ts +101 -0
- package/src/components/DateTimePicker/DateTimePicker.test.tsx +60 -186
- package/src/components/DateTimePicker/DateTimePicker.tsx +209 -181
- package/src/components/DateTimePicker/dateTimePicker.types.ts +50 -54
- package/src/components/DateTimePicker/dateTimePicker.variants.ts +22 -51
- package/src/components/Tabs/Tabs.test.tsx +85 -14
- package/src/components/Tabs/Tabs.tsx +35 -24
- package/src/components/Tabs/tabs.types.ts +8 -2
- package/src/components/Tabs/useTabs.ts +167 -0
- package/src/components/TimePicker/TimePicker.test.tsx +65 -0
- package/src/components/TimePicker/TimePicker.tsx +274 -0
- package/src/components/TimePicker/timePicker.types.ts +69 -0
- package/src/components/TimePicker/timePicker.variants.ts +53 -0
- package/src/index.ts +28 -16
- 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
|
+
}
|