@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
@@ -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
- * Resolve the native HTML input `type` for a given picker mode.
22
- */
23
- function inputTypeFor(mode: DateTimePickerMode): string {
24
- switch (mode) {
25
- case 'time':
26
- return 'time';
27
- case 'datetime':
28
- return 'datetime-local';
29
- case 'date':
30
- default:
31
- return 'date';
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
- // Fast path: already in the expected shape.
59
- if (mode === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
60
- if (mode === 'time' && /^\d{2}:\d{2}(:\d{2})?$/.test(raw)) return raw;
61
- if (
62
- mode === 'datetime' &&
63
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/.test(raw)
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
- // Slow path: parse via Date and re-format locally.
69
- const d = new Date(raw);
70
- if (isNaN(d.getTime())) return '';
71
-
72
- const pad = (n: number) => String(n).padStart(2, '0');
73
- const yyyy = d.getFullYear();
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
- * Take a native input string and return the canonical "stored" value:
86
- * the same string for non-empty input, or `null` for empty (so the
87
- * bridge / consumer never receives the ambiguous empty string).
88
- */
89
- function inputValueToStored(raw: string): string | null {
90
- return raw === '' ? null : raw;
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 — bridge-integrated native date/time input.
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
- * **Why native inputs instead of React Aria `<DatePicker>`?**
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
- * 1. Zero new deps (`@internationalized/date` would add ~30 KB).
104
- * 2. Mirrors `@dashforge/ui/DateTimePicker` (MUI side) for API parity.
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
- min,
140
- max,
141
- step,
116
+ layout = 'stacked',
117
+ visibleWhen,
118
+ access,
142
119
  value: explicitValue,
143
120
  defaultValue,
144
- onValueChange,
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 inputId = useId();
157
- const helperId = `${inputId}-help`;
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 effectiveReadOnly = accessState.readonly;
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 | undefined;
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
- // Pure uncontrolled mode: defer to native input's defaultValue.
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 inputRef = useCallback(
207
- (instance: HTMLInputElement | null) => {
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
- size,
221
- layout,
222
- error: resolvedError,
223
- fullWidth,
224
- disabled: effectiveDisabled,
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 inputType = inputTypeFor(mode);
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
- onValueChange?.(next);
220
+ onChange?.(next);
247
221
  };
248
222
 
249
- const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
223
+ const markTouched = () => {
250
224
  if (isFormMode && bridge) {
251
- registration?.onBlur?.(event);
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 className={cn(v.root(), sx, slotProps?.root?.className)}>
255
+ <div
256
+ data-testid={testId}
257
+ className={cn(v.root(), sx, slotProps?.root?.className)}
258
+ >
257
259
  {label && (
258
260
  <label
259
- htmlFor={inputId}
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
- <div
278
- className={cn(v.inputWrapper(), slotProps?.inputWrapper?.className)}
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
- <input
281
- id={inputId}
282
- ref={inputRef}
283
- name={name}
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
- onChange={handleChange}
299
- onBlur={handleBlur}
300
- className={cn(v.input(), slotProps?.input?.className)}
301
- />
302
- </div>
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 { DateTimePickerVariants } from './dateTimePicker.variants.js';
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
- inputWrapper?: { className?: string };
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 richer "always-visible calendar grid" upgrade can come as F5-B-bis.
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
- * Value contract: ISO 8601-ish naive strings, always.
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 extends DateTimePickerVariants {
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
- /** Which native input to render. @default 'date' */
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
- required?: boolean;
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
- /** Minimum value (ISO string compatible with the mode). */
61
- min?: string;
62
- /** Maximum value (ISO string compatible with the mode). */
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
- /** Root className shortcut. */
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
- /** Default value for uncontrolled mode (no-op in form mode). */
51
+ /** Uncontrolled initial value. */
80
52
  defaultValue?: string | null;
81
- /** Fires when the user picks a date/time (after bridge update in form mode). */
82
- onValueChange?: (value: string | null) => void;
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
  }