@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
|
@@ -1,166 +1,154 @@
|
|
|
1
|
-
import {
|
|
2
|
-
useCallback,
|
|
3
|
-
useContext,
|
|
4
|
-
useEffect,
|
|
5
|
-
useId,
|
|
6
|
-
useRef,
|
|
7
|
-
} from 'react';
|
|
1
|
+
import { useCallback, useContext, useEffect, useId, useRef, useState } from 'react';
|
|
8
2
|
import { DashFormContext, useEngineVisibility } from '@dashforge/ui-core';
|
|
9
3
|
import type { DashFormBridge, FieldRegistration } from '@dashforge/ui-core';
|
|
10
4
|
import { useDashFieldMeta } from '@dashforge/forms';
|
|
5
|
+
import {
|
|
6
|
+
formatTime,
|
|
7
|
+
generateTimeOptions,
|
|
8
|
+
getTodayISODate,
|
|
9
|
+
parseISODate,
|
|
10
|
+
} from '@dashforge/calendar-core';
|
|
11
|
+
import type { ISODate } from '@dashforge/calendar-core';
|
|
11
12
|
import { cn } from '../../utils/cn.js';
|
|
12
13
|
import { useAccessState } from '../../hooks/useAccessState.js';
|
|
13
14
|
import { resolveValidationState } from '../_shared/resolveValidationState.js';
|
|
15
|
+
import { Popover } from '../Popover/Popover.js';
|
|
16
|
+
import { Calendar } from '../Calendar/Calendar.js';
|
|
14
17
|
import { dateTimePickerVariants } from './dateTimePicker.variants.js';
|
|
15
|
-
import type {
|
|
16
|
-
DateTimePickerMode,
|
|
17
|
-
DateTimePickerProps,
|
|
18
|
-
} from './dateTimePicker.types.js';
|
|
18
|
+
import type { DateTimePickerProps } from './dateTimePicker.types.js';
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
20
|
+
// Inline 16×16 stroke calendar glyph — no icon dependency (tw convention).
|
|
21
|
+
function CalendarIcon() {
|
|
22
|
+
return (
|
|
23
|
+
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
24
|
+
<rect
|
|
25
|
+
x="2.5"
|
|
26
|
+
y="3.5"
|
|
27
|
+
width="11"
|
|
28
|
+
height="10"
|
|
29
|
+
rx="1.5"
|
|
30
|
+
stroke="currentColor"
|
|
31
|
+
strokeWidth="1.5"
|
|
32
|
+
/>
|
|
33
|
+
<path
|
|
34
|
+
d="M2.5 6.5h11M5.5 2v3M10.5 2v3"
|
|
35
|
+
stroke="currentColor"
|
|
36
|
+
strokeWidth="1.5"
|
|
37
|
+
strokeLinecap="round"
|
|
38
|
+
/>
|
|
39
|
+
</svg>
|
|
40
|
+
);
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
* Coerce a stored/exposed value (ISO 8601-ish string or `null`/`undefined`)
|
|
37
|
-
* into the format the native input expects.
|
|
38
|
-
*
|
|
39
|
-
* Contract:
|
|
40
|
-
* - `date` → `YYYY-MM-DD`
|
|
41
|
-
* - `time` → `HH:mm` (or `HH:mm:ss` if seconds present)
|
|
42
|
-
* - `datetime` → `YYYY-MM-DDTHH:mm` (no timezone, datetime-local
|
|
43
|
-
* rejects `Z` / offset suffixes)
|
|
44
|
-
*
|
|
45
|
-
* The function is intentionally lenient:
|
|
46
|
-
* - Pass-through when the input already matches the expected shape.
|
|
47
|
-
* - Truncates a richer ISO string (e.g. `2026-05-16T10:23:45.000Z`)
|
|
48
|
-
* down to the mode-appropriate prefix.
|
|
49
|
-
* - Returns `''` for null / undefined / unparseable inputs (native
|
|
50
|
-
* inputs use `''` to represent "no value").
|
|
51
|
-
*/
|
|
52
|
-
export function isoToInputValue(
|
|
53
|
-
mode: DateTimePickerMode,
|
|
54
|
-
raw: string | null | undefined
|
|
55
|
-
): string {
|
|
56
|
-
if (raw == null || raw === '') return '';
|
|
43
|
+
const DATETIME_PATTERN = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/;
|
|
57
44
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return raw;
|
|
45
|
+
/** Splits a stored `YYYY-MM-DDTHH:mm` value into its date and time parts. */
|
|
46
|
+
function splitDateTime(value: string | null | undefined): {
|
|
47
|
+
date: ISODate | null;
|
|
48
|
+
time: string | null;
|
|
49
|
+
} {
|
|
50
|
+
if (value == null || value === '') {
|
|
51
|
+
return { date: null, time: null };
|
|
66
52
|
}
|
|
53
|
+
const match = DATETIME_PATTERN.exec(value);
|
|
54
|
+
if (match !== null) {
|
|
55
|
+
return {
|
|
56
|
+
date: match[1] ?? null,
|
|
57
|
+
time: `${match[2] ?? '00'}:${match[3] ?? '00'}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { date: parseISODate(value) !== null ? value : null, time: null };
|
|
61
|
+
}
|
|
67
62
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const mm = pad(d.getMonth() + 1);
|
|
75
|
-
const dd = pad(d.getDate());
|
|
76
|
-
const hh = pad(d.getHours());
|
|
77
|
-
const mi = pad(d.getMinutes());
|
|
78
|
-
|
|
79
|
-
if (mode === 'date') return `${yyyy}-${mm}-${dd}`;
|
|
80
|
-
if (mode === 'time') return `${hh}:${mi}`;
|
|
81
|
-
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
|
|
63
|
+
/** Combines a date and a time into a `YYYY-MM-DDTHH:mm` value (or `null`). */
|
|
64
|
+
function joinDateTime(date: ISODate | null, time: string | null): string | null {
|
|
65
|
+
if (date === null && time === null) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return `${date ?? getTodayISODate()}T${time ?? '00:00'}`;
|
|
82
69
|
}
|
|
83
70
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
71
|
+
/** Formats a stored datetime for the trigger display. */
|
|
72
|
+
function formatDateTime(
|
|
73
|
+
value: string | null,
|
|
74
|
+
locale: string,
|
|
75
|
+
hour12: boolean,
|
|
76
|
+
): string {
|
|
77
|
+
const { date, time } = splitDateTime(value);
|
|
78
|
+
const parts: string[] = [];
|
|
79
|
+
if (date !== null) {
|
|
80
|
+
const parsed = parseISODate(date);
|
|
81
|
+
parts.push(
|
|
82
|
+
parsed === null
|
|
83
|
+
? date
|
|
84
|
+
: new Intl.DateTimeFormat(locale, {
|
|
85
|
+
dateStyle: 'medium',
|
|
86
|
+
timeZone: 'UTC',
|
|
87
|
+
}).format(Date.UTC(parsed.year, parsed.month - 1, parsed.day)),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (time !== null) {
|
|
91
|
+
parts.push(formatTime(time, { hour12 }));
|
|
92
|
+
}
|
|
93
|
+
return parts.join(', ');
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
/**
|
|
94
|
-
* Dashforge TW DateTimePicker —
|
|
95
|
-
*
|
|
96
|
-
* Three modes (`mode` prop):
|
|
97
|
-
* - `date` — `<input type="date">` → `YYYY-MM-DD`
|
|
98
|
-
* - `time` — `<input type="time">` → `HH:mm` (`HH:mm:ss` with `step={1}`)
|
|
99
|
-
* - `datetime` — `<input type="datetime-local">` → `YYYY-MM-DDTHH:mm`
|
|
97
|
+
* Dashforge TW `<DateTimePicker>` — a form-bound date + time field.
|
|
100
98
|
*
|
|
101
|
-
*
|
|
99
|
+
* A read-only trigger button paired with a popover combining a `<Calendar>`
|
|
100
|
+
* and a time list (Radix Popover). Integrates with the Dashforge form
|
|
101
|
+
* bridge + RBAC.
|
|
102
102
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* 3. Native inputs are AAA-grade a11y out of the box — screen readers,
|
|
106
|
-
* keyboard nav, OS-provided calendar/clock UI.
|
|
107
|
-
* 4. Predictable state (no internal Aria state machine that fights
|
|
108
|
-
* controlled props — the F5-A lesson learned in `<Autocomplete>`).
|
|
109
|
-
*
|
|
110
|
-
* A richer custom-calendar version (range mode, locale awareness,
|
|
111
|
-
* custom popover styling) can land as F5-B-bis built on Aria
|
|
112
|
-
* `<DatePicker>` / `<Calendar>`.
|
|
113
|
-
*
|
|
114
|
-
* **A11y**:
|
|
115
|
-
* - `aria-invalid` driven by the resolved validation state.
|
|
116
|
-
* - `aria-describedby` links input ↔ helper/error.
|
|
117
|
-
* - Required fields get the native `required` attribute + a visual `*`.
|
|
118
|
-
* - `color-scheme: light dark` keeps the OS calendar icon readable
|
|
119
|
-
* in both color schemes.
|
|
103
|
+
* Storage contract: a naive ISO datetime `"YYYY-MM-DDTHH:mm"` — no seconds,
|
|
104
|
+
* no timezone. Shared with the MUI `@dashforge/ui` `DateTimePicker`.
|
|
120
105
|
*/
|
|
121
106
|
export function DateTimePicker(props: DateTimePickerProps) {
|
|
122
107
|
const {
|
|
123
108
|
name,
|
|
124
109
|
rules,
|
|
125
|
-
mode = 'date',
|
|
126
|
-
visibleWhen,
|
|
127
|
-
layout = 'stacked',
|
|
128
|
-
size,
|
|
129
110
|
label,
|
|
130
111
|
helperText,
|
|
131
|
-
required,
|
|
132
112
|
error,
|
|
113
|
+
required,
|
|
133
114
|
disabled,
|
|
134
|
-
fullWidth,
|
|
135
|
-
access,
|
|
136
|
-
sx,
|
|
137
|
-
slotProps,
|
|
138
115
|
placeholder,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
116
|
+
layout = 'stacked',
|
|
117
|
+
visibleWhen,
|
|
118
|
+
access,
|
|
142
119
|
value: explicitValue,
|
|
143
120
|
defaultValue,
|
|
144
|
-
|
|
121
|
+
onChange,
|
|
122
|
+
minDate,
|
|
123
|
+
maxDate,
|
|
124
|
+
disabledDates,
|
|
125
|
+
isDateDisabled,
|
|
126
|
+
weekStartDay,
|
|
127
|
+
locale,
|
|
128
|
+
stepMinutes,
|
|
129
|
+
hour12 = false,
|
|
130
|
+
fullWidth,
|
|
131
|
+
sx,
|
|
132
|
+
slotProps,
|
|
133
|
+
testId,
|
|
145
134
|
} = props;
|
|
146
135
|
|
|
147
|
-
// ───── Hooks (unconditional) ─────
|
|
148
136
|
const bridge = useContext(DashFormContext) as DashFormBridge | null;
|
|
149
137
|
const isVisible = useEngineVisibility(bridge?.engine, visibleWhen);
|
|
150
|
-
// Per-field reactive snapshot. Same pattern as <TextField> — re-renders
|
|
151
|
-
// this component when its OWN bridge value changes, but not when a
|
|
152
|
-
// sibling field changes (perf contract).
|
|
153
138
|
const fieldMeta = useDashFieldMeta(name);
|
|
154
139
|
const accessState = useAccessState(access);
|
|
155
140
|
|
|
156
|
-
const
|
|
157
|
-
const helperId = `${
|
|
141
|
+
const fieldId = useId();
|
|
142
|
+
const helperId = `${fieldId}-help`;
|
|
143
|
+
|
|
144
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
145
|
+
const [internalValue, setInternalValue] = useState<string | null>(
|
|
146
|
+
defaultValue ?? null,
|
|
147
|
+
);
|
|
158
148
|
|
|
159
|
-
// StrictMode-safe unregister-on-unmount.
|
|
160
149
|
const unregisterRef = useRef({ bridge, name });
|
|
161
150
|
unregisterRef.current = { bridge, name };
|
|
162
151
|
const isMountedRef = useRef(false);
|
|
163
|
-
|
|
164
152
|
useEffect(() => {
|
|
165
153
|
isMountedRef.current = true;
|
|
166
154
|
return () => {
|
|
@@ -172,15 +160,14 @@ export function DateTimePicker(props: DateTimePickerProps) {
|
|
|
172
160
|
};
|
|
173
161
|
}, []);
|
|
174
162
|
|
|
175
|
-
// ───── Derived ─────
|
|
176
163
|
const effectiveDisabled = Boolean(disabled) || accessState.disabled;
|
|
177
|
-
const
|
|
164
|
+
const isInteractive = !effectiveDisabled && !accessState.readonly;
|
|
178
165
|
const isFormMode = Boolean(bridge?.register);
|
|
179
166
|
|
|
180
167
|
let resolvedError = error;
|
|
181
168
|
let resolvedHelperText: typeof helperText = helperText;
|
|
182
169
|
let registration: FieldRegistration | null = null;
|
|
183
|
-
let resolvedValue: string | null
|
|
170
|
+
let resolvedValue: string | null;
|
|
184
171
|
|
|
185
172
|
if (isFormMode && bridge) {
|
|
186
173
|
registration = bridge.register(name, rules);
|
|
@@ -196,77 +183,89 @@ export function DateTimePicker(props: DateTimePickerProps) {
|
|
|
196
183
|
} else if (explicitValue !== undefined) {
|
|
197
184
|
resolvedValue = explicitValue;
|
|
198
185
|
} else {
|
|
199
|
-
|
|
200
|
-
resolvedValue = undefined;
|
|
186
|
+
resolvedValue = internalValue;
|
|
201
187
|
}
|
|
202
188
|
|
|
203
|
-
// Registration-supplied callback ref (RHF threads its own ref through
|
|
204
|
-
// here in form mode). Stable callback to keep React 19 happy.
|
|
205
189
|
const registrationRefFn = registration?.ref;
|
|
206
|
-
const
|
|
207
|
-
(instance:
|
|
190
|
+
const triggerRef = useCallback(
|
|
191
|
+
(instance: HTMLButtonElement | null) => {
|
|
208
192
|
if (typeof registrationRefFn === 'function') {
|
|
209
193
|
registrationRefFn(instance);
|
|
210
194
|
}
|
|
211
195
|
},
|
|
212
|
-
[registrationRefFn]
|
|
196
|
+
[registrationRefFn],
|
|
213
197
|
);
|
|
214
198
|
|
|
215
|
-
// ───── Render-time guards (after all hooks) ─────
|
|
216
199
|
if (!isVisible) return null;
|
|
217
200
|
if (!accessState.visible) return null;
|
|
218
201
|
|
|
219
|
-
const v = dateTimePickerVariants({
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
202
|
+
const v = dateTimePickerVariants({ layout, error: resolvedError, fullWidth });
|
|
203
|
+
const resolvedLocale = locale ?? 'en-US';
|
|
204
|
+
const { date, time } = splitDateTime(resolvedValue);
|
|
205
|
+
const displayValue = formatDateTime(resolvedValue, resolvedLocale, hour12);
|
|
206
|
+
const options = generateTimeOptions({
|
|
207
|
+
...(stepMinutes !== undefined && { stepMinutes }),
|
|
225
208
|
});
|
|
226
209
|
|
|
227
|
-
const
|
|
228
|
-
const nativeValue =
|
|
229
|
-
resolvedValue === undefined ? undefined : isoToInputValue(mode, resolvedValue);
|
|
230
|
-
const nativeDefault =
|
|
231
|
-
!isFormMode && explicitValue === undefined && defaultValue !== undefined
|
|
232
|
-
? isoToInputValue(mode, defaultValue)
|
|
233
|
-
: undefined;
|
|
234
|
-
const nativeMin = min != null ? isoToInputValue(mode, min) : undefined;
|
|
235
|
-
const nativeMax = max != null ? isoToInputValue(mode, max) : undefined;
|
|
236
|
-
|
|
237
|
-
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
238
|
-
const next = inputValueToStored(event.target.value);
|
|
210
|
+
const commitValue = (next: string | null) => {
|
|
239
211
|
if (isFormMode && bridge) {
|
|
240
212
|
bridge.setValue?.(name, next);
|
|
241
213
|
void registration?.onChange?.({
|
|
242
214
|
target: { name, value: next ?? '' },
|
|
243
215
|
type: 'change',
|
|
244
216
|
});
|
|
217
|
+
} else if (explicitValue === undefined) {
|
|
218
|
+
setInternalValue(next);
|
|
245
219
|
}
|
|
246
|
-
|
|
220
|
+
onChange?.(next);
|
|
247
221
|
};
|
|
248
222
|
|
|
249
|
-
const
|
|
223
|
+
const markTouched = () => {
|
|
250
224
|
if (isFormMode && bridge) {
|
|
251
|
-
|
|
225
|
+
const committed = bridge.getValue(name);
|
|
226
|
+
void registration?.onBlur?.({
|
|
227
|
+
target: { name, value: committed == null ? '' : String(committed) },
|
|
228
|
+
type: 'blur',
|
|
229
|
+
});
|
|
252
230
|
}
|
|
253
231
|
};
|
|
254
232
|
|
|
233
|
+
const handleOpenChange = (next: boolean) => {
|
|
234
|
+
if (next && !isInteractive) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
setIsOpen(next);
|
|
238
|
+
if (!next) {
|
|
239
|
+
markTouched();
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Picking a date keeps the popover open — the user still needs a time.
|
|
244
|
+
const handleDateSelect = (nextDate: ISODate) => {
|
|
245
|
+
commitValue(joinDateTime(nextDate, time));
|
|
246
|
+
};
|
|
247
|
+
// Picking a time completes the value and closes the popover.
|
|
248
|
+
const handleTimeSelect = (nextTime: string) => {
|
|
249
|
+
commitValue(joinDateTime(date, nextTime));
|
|
250
|
+
setIsOpen(false);
|
|
251
|
+
markTouched();
|
|
252
|
+
};
|
|
253
|
+
|
|
255
254
|
return (
|
|
256
|
-
<div
|
|
255
|
+
<div
|
|
256
|
+
data-testid={testId}
|
|
257
|
+
className={cn(v.root(), sx, slotProps?.root?.className)}
|
|
258
|
+
>
|
|
257
259
|
{label && (
|
|
258
260
|
<label
|
|
259
|
-
htmlFor={
|
|
261
|
+
htmlFor={fieldId}
|
|
260
262
|
className={cn(v.label(), slotProps?.label?.className)}
|
|
261
263
|
>
|
|
262
264
|
{label}
|
|
263
265
|
{required && (
|
|
264
266
|
<span
|
|
265
267
|
aria-hidden="true"
|
|
266
|
-
className={cn(
|
|
267
|
-
v.requiredMark(),
|
|
268
|
-
slotProps?.requiredMark?.className
|
|
269
|
-
)}
|
|
268
|
+
className={cn(v.requiredMark(), slotProps?.requiredMark?.className)}
|
|
270
269
|
>
|
|
271
270
|
*
|
|
272
271
|
</span>
|
|
@@ -274,32 +273,61 @@ export function DateTimePicker(props: DateTimePickerProps) {
|
|
|
274
273
|
</label>
|
|
275
274
|
)}
|
|
276
275
|
|
|
277
|
-
<
|
|
278
|
-
|
|
276
|
+
<Popover
|
|
277
|
+
open={isOpen}
|
|
278
|
+
onOpenChange={handleOpenChange}
|
|
279
|
+
side="bottom"
|
|
280
|
+
align="start"
|
|
281
|
+
content={
|
|
282
|
+
<div className={v.panel()}>
|
|
283
|
+
<Calendar
|
|
284
|
+
value={date}
|
|
285
|
+
onChange={handleDateSelect}
|
|
286
|
+
minDate={minDate}
|
|
287
|
+
maxDate={maxDate}
|
|
288
|
+
disabledDates={disabledDates}
|
|
289
|
+
isDateDisabled={isDateDisabled}
|
|
290
|
+
weekStartDay={weekStartDay}
|
|
291
|
+
locale={locale}
|
|
292
|
+
aria-label="Choose date"
|
|
293
|
+
sx="border-0 bg-transparent p-0"
|
|
294
|
+
/>
|
|
295
|
+
<div role="listbox" aria-label="Time options" className={v.list()}>
|
|
296
|
+
{options.map((option) => (
|
|
297
|
+
<button
|
|
298
|
+
key={option}
|
|
299
|
+
type="button"
|
|
300
|
+
role="option"
|
|
301
|
+
aria-selected={option === time}
|
|
302
|
+
className={v.option()}
|
|
303
|
+
onClick={() => {
|
|
304
|
+
handleTimeSelect(option);
|
|
305
|
+
}}
|
|
306
|
+
>
|
|
307
|
+
{formatTime(option, { hour12 })}
|
|
308
|
+
</button>
|
|
309
|
+
))}
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
}
|
|
279
313
|
>
|
|
280
|
-
<
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
type={inputType}
|
|
285
|
-
// Controlled in form / explicit-value mode; uncontrolled with
|
|
286
|
-
// `defaultValue` otherwise. Mirrors the TextField pattern.
|
|
287
|
-
value={nativeValue}
|
|
288
|
-
defaultValue={nativeDefault}
|
|
289
|
-
min={nativeMin}
|
|
290
|
-
max={nativeMax}
|
|
291
|
-
step={step}
|
|
292
|
-
placeholder={placeholder}
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
id={fieldId}
|
|
317
|
+
ref={triggerRef}
|
|
293
318
|
disabled={effectiveDisabled}
|
|
294
|
-
readOnly={effectiveReadOnly}
|
|
295
|
-
required={required}
|
|
296
319
|
aria-invalid={resolvedError ? true : undefined}
|
|
297
320
|
aria-describedby={resolvedHelperText ? helperId : undefined}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
className={
|
|
301
|
-
|
|
302
|
-
|
|
321
|
+
className={cn(v.trigger(), slotProps?.trigger?.className)}
|
|
322
|
+
>
|
|
323
|
+
<span className={displayValue ? v.value() : v.placeholder()}>
|
|
324
|
+
{displayValue || placeholder || 'Select date and time'}
|
|
325
|
+
</span>
|
|
326
|
+
<span className={v.icon()}>
|
|
327
|
+
<CalendarIcon />
|
|
328
|
+
</span>
|
|
329
|
+
</button>
|
|
330
|
+
</Popover>
|
|
303
331
|
|
|
304
332
|
{resolvedHelperText && (
|
|
305
333
|
<p
|
|
@@ -308,7 +336,7 @@ export function DateTimePicker(props: DateTimePickerProps) {
|
|
|
308
336
|
resolvedError ? v.errorText() : v.helperText(),
|
|
309
337
|
resolvedError
|
|
310
338
|
? slotProps?.errorText?.className
|
|
311
|
-
: slotProps?.helperText?.className
|
|
339
|
+
: slotProps?.helperText?.className,
|
|
312
340
|
)}
|
|
313
341
|
>
|
|
314
342
|
{resolvedHelperText}
|
|
@@ -1,83 +1,79 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import type { Engine } from '@dashforge/ui-core';
|
|
3
3
|
import type { AccessRequirement } from '@dashforge/rbac';
|
|
4
|
-
import type {
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Operating mode of the picker.
|
|
8
|
-
*
|
|
9
|
-
* - `'date'` — `YYYY-MM-DD` (native `<input type="date">`)
|
|
10
|
-
* - `'time'` — `HH:mm` or `HH:mm:ss` (native `<input type="time">`)
|
|
11
|
-
* - `'datetime'` — `YYYY-MM-DDTHH:mm` (native `<input type="datetime-local">`)
|
|
12
|
-
*
|
|
13
|
-
* Note: `datetime-local` does NOT include timezone info. Values are
|
|
14
|
-
* naive local-time strings. If you need TZ-aware persistence, convert
|
|
15
|
-
* upstream (e.g., interpret as UTC, or attach the user's TZ from a
|
|
16
|
-
* separate field).
|
|
17
|
-
*/
|
|
18
|
-
export type DateTimePickerMode = 'date' | 'time' | 'datetime';
|
|
4
|
+
import type { ISODate, WeekDay } from '@dashforge/calendar-core';
|
|
19
5
|
|
|
6
|
+
/** Per-slot `className` overrides for `<DateTimePicker>`. */
|
|
20
7
|
export interface DateTimePickerSlotProps {
|
|
21
8
|
root?: { className?: string };
|
|
22
9
|
label?: { className?: string };
|
|
23
10
|
requiredMark?: { className?: string };
|
|
24
|
-
|
|
25
|
-
input?: { className?: string };
|
|
11
|
+
trigger?: { className?: string };
|
|
26
12
|
helperText?: { className?: string };
|
|
27
13
|
errorText?: { className?: string };
|
|
28
14
|
}
|
|
29
15
|
|
|
30
16
|
/**
|
|
31
|
-
* Props for `<DateTimePicker
|
|
32
|
-
*
|
|
33
|
-
* Bridge-integrated date / time / datetime input built on the native
|
|
34
|
-
* HTML5 input types (`type="date|time|datetime-local"`). Chosen over a
|
|
35
|
-
* custom React Aria `<DatePicker>` for F5-B because:
|
|
36
|
-
*
|
|
37
|
-
* 1. **Zero new deps** — no `@internationalized/date`.
|
|
38
|
-
* 2. **A11y by default** — screen readers know how to announce these
|
|
39
|
-
* inputs, including the OS-provided calendar/clock popup.
|
|
40
|
-
* 3. **Bundle size** — saves ~30KB minified over the Aria stack.
|
|
41
|
-
* 4. **API parity** with the MUI side (`@dashforge/ui/DateTimePicker`).
|
|
42
|
-
* 5. **Predictability** — no internal state machine to fight (see the
|
|
43
|
-
* ComboBox lesson learned in `<Autocomplete>` F5-A).
|
|
17
|
+
* Props for the `<DateTimePicker>` form field (Tailwind skin).
|
|
44
18
|
*
|
|
45
|
-
* A
|
|
19
|
+
* A read-only trigger paired with a popover combining a `<Calendar>` and a
|
|
20
|
+
* time list. The stored value is a naive ISO datetime — `"YYYY-MM-DDTHH:mm"`
|
|
21
|
+
* — or `null`. No seconds, no timezone.
|
|
46
22
|
*
|
|
47
|
-
*
|
|
23
|
+
* For a date-only field use `<DatePicker>`; for time-only use `<TimePicker>`.
|
|
24
|
+
* The prop surface mirrors the MUI `@dashforge/ui` `DateTimePicker`.
|
|
48
25
|
*/
|
|
49
|
-
export interface DateTimePickerProps
|
|
26
|
+
export interface DateTimePickerProps {
|
|
27
|
+
/** Field name — the bridge registration key. Required. */
|
|
50
28
|
name: string;
|
|
29
|
+
/** Validation rules forwarded to the form bridge. */
|
|
51
30
|
rules?: unknown;
|
|
52
|
-
/**
|
|
53
|
-
mode?: DateTimePickerMode;
|
|
31
|
+
/** Field label. */
|
|
54
32
|
label?: ReactNode;
|
|
33
|
+
/** Helper text below the control (overrides a bridge error message). */
|
|
55
34
|
helperText?: ReactNode;
|
|
56
|
-
|
|
35
|
+
/** Explicit error state (overrides the bridge's auto error). */
|
|
57
36
|
error?: boolean;
|
|
37
|
+
/** Marks the field required (adds the label asterisk). */
|
|
38
|
+
required?: boolean;
|
|
39
|
+
/** Disables the field. */
|
|
58
40
|
disabled?: boolean;
|
|
41
|
+
/** Placeholder shown when no value is selected. */
|
|
59
42
|
placeholder?: string;
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
max?: string;
|
|
64
|
-
/**
|
|
65
|
-
* Step in seconds (time / datetime). Default 60 ⇒ minute precision.
|
|
66
|
-
* Set to 1 to allow seconds.
|
|
67
|
-
*/
|
|
68
|
-
step?: number;
|
|
69
|
-
/** Engine predicate — field not rendered when it returns `false`. */
|
|
43
|
+
/** Label/control layout. */
|
|
44
|
+
layout?: 'stacked' | 'inline';
|
|
45
|
+
/** Reactive visibility predicate evaluated against the form engine. */
|
|
70
46
|
visibleWhen?: (engine: Engine) => boolean;
|
|
71
47
|
/** RBAC access requirement. */
|
|
72
48
|
access?: AccessRequirement;
|
|
73
|
-
/**
|
|
74
|
-
sx?: string;
|
|
75
|
-
/** Per-slot className overrides. */
|
|
76
|
-
slotProps?: DateTimePickerSlotProps;
|
|
77
|
-
/** Controlled value (form mode reads from the bridge if omitted). */
|
|
49
|
+
/** Controlled value — `"YYYY-MM-DDTHH:mm"` or `null`. */
|
|
78
50
|
value?: string | null;
|
|
79
|
-
/**
|
|
51
|
+
/** Uncontrolled initial value. */
|
|
80
52
|
defaultValue?: string | null;
|
|
81
|
-
/**
|
|
82
|
-
|
|
53
|
+
/** Fired with the new datetime (or `null` when cleared). */
|
|
54
|
+
onChange?: (value: string | null) => void;
|
|
55
|
+
/** Earliest selectable date (inclusive). */
|
|
56
|
+
minDate?: ISODate;
|
|
57
|
+
/** Latest selectable date (inclusive). */
|
|
58
|
+
maxDate?: ISODate;
|
|
59
|
+
/** Explicit list of disabled dates. */
|
|
60
|
+
disabledDates?: readonly ISODate[];
|
|
61
|
+
/** Predicate marking arbitrary dates disabled. */
|
|
62
|
+
isDateDisabled?: (date: ISODate) => boolean;
|
|
63
|
+
/** Weekday of the calendar's first column (`0` = Sunday). */
|
|
64
|
+
weekStartDay?: WeekDay;
|
|
65
|
+
/** BCP-47 locale for the calendar and the display format. */
|
|
66
|
+
locale?: string;
|
|
67
|
+
/** Step between time-list options, in minutes. Default `30`. */
|
|
68
|
+
stepMinutes?: number;
|
|
69
|
+
/** Render the time list in 12-hour notation. Default `false`. */
|
|
70
|
+
hour12?: boolean;
|
|
71
|
+
/** Stretches the field to its container width. */
|
|
72
|
+
fullWidth?: boolean;
|
|
73
|
+
/** Root-level Tailwind class override. */
|
|
74
|
+
sx?: string;
|
|
75
|
+
/** Per-slot `className` overrides. */
|
|
76
|
+
slotProps?: DateTimePickerSlotProps;
|
|
77
|
+
/** Test id applied to the field root. */
|
|
78
|
+
testId?: string;
|
|
83
79
|
}
|