@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,274 @@
|
|
|
1
|
+
import { useCallback, useContext, useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
import { DashFormContext, useEngineVisibility } from '@dashforge/ui-core';
|
|
3
|
+
import type { DashFormBridge, FieldRegistration } from '@dashforge/ui-core';
|
|
4
|
+
import { useDashFieldMeta } from '@dashforge/forms';
|
|
5
|
+
import {
|
|
6
|
+
formatTime,
|
|
7
|
+
generateTimeOptions,
|
|
8
|
+
timeStringToMinutes,
|
|
9
|
+
} from '@dashforge/calendar-core';
|
|
10
|
+
import { cn } from '../../utils/cn.js';
|
|
11
|
+
import { useAccessState } from '../../hooks/useAccessState.js';
|
|
12
|
+
import { resolveValidationState } from '../_shared/resolveValidationState.js';
|
|
13
|
+
import { Popover } from '../Popover/Popover.js';
|
|
14
|
+
import { timePickerVariants } from './timePicker.variants.js';
|
|
15
|
+
import type { TimePickerProps } from './timePicker.types.js';
|
|
16
|
+
|
|
17
|
+
// Inline 16×16 stroke clock glyph — no icon dependency (tw convention).
|
|
18
|
+
function ClockIcon() {
|
|
19
|
+
return (
|
|
20
|
+
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
21
|
+
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
|
|
22
|
+
<path
|
|
23
|
+
d="M8 4.5V8l2.4 1.4"
|
|
24
|
+
stroke="currentColor"
|
|
25
|
+
strokeWidth="1.5"
|
|
26
|
+
strokeLinecap="round"
|
|
27
|
+
strokeLinejoin="round"
|
|
28
|
+
/>
|
|
29
|
+
</svg>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Dashforge TW `<TimePicker>` — a form-bound time-of-day field.
|
|
35
|
+
*
|
|
36
|
+
* A read-only trigger button paired with a time-list popover (Radix
|
|
37
|
+
* Popover). Integrates with the Dashforge form bridge + RBAC.
|
|
38
|
+
*
|
|
39
|
+
* Storage contract: a canonical 24-hour `"HH:mm"` string, or `null` —
|
|
40
|
+
* shared with the MUI `@dashforge/ui` `TimePicker`. `hour12` is display-only.
|
|
41
|
+
*/
|
|
42
|
+
export function TimePicker(props: TimePickerProps) {
|
|
43
|
+
const {
|
|
44
|
+
name,
|
|
45
|
+
rules,
|
|
46
|
+
label,
|
|
47
|
+
helperText,
|
|
48
|
+
error,
|
|
49
|
+
required,
|
|
50
|
+
disabled,
|
|
51
|
+
placeholder,
|
|
52
|
+
layout = 'stacked',
|
|
53
|
+
visibleWhen,
|
|
54
|
+
access,
|
|
55
|
+
value: explicitValue,
|
|
56
|
+
defaultValue,
|
|
57
|
+
onChange,
|
|
58
|
+
minTime,
|
|
59
|
+
maxTime,
|
|
60
|
+
stepMinutes,
|
|
61
|
+
hour12 = false,
|
|
62
|
+
fullWidth,
|
|
63
|
+
sx,
|
|
64
|
+
slotProps,
|
|
65
|
+
testId,
|
|
66
|
+
} = props;
|
|
67
|
+
|
|
68
|
+
const bridge = useContext(DashFormContext) as DashFormBridge | null;
|
|
69
|
+
const isVisible = useEngineVisibility(bridge?.engine, visibleWhen);
|
|
70
|
+
const fieldMeta = useDashFieldMeta(name);
|
|
71
|
+
const accessState = useAccessState(access);
|
|
72
|
+
|
|
73
|
+
const fieldId = useId();
|
|
74
|
+
const helperId = `${fieldId}-help`;
|
|
75
|
+
|
|
76
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
77
|
+
const [internalValue, setInternalValue] = useState<string | null>(
|
|
78
|
+
defaultValue ?? null,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const unregisterRef = useRef({ bridge, name });
|
|
82
|
+
unregisterRef.current = { bridge, name };
|
|
83
|
+
const isMountedRef = useRef(false);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
isMountedRef.current = true;
|
|
86
|
+
return () => {
|
|
87
|
+
isMountedRef.current = false;
|
|
88
|
+
const { bridge: cap, name: capName } = unregisterRef.current;
|
|
89
|
+
queueMicrotask(() => {
|
|
90
|
+
if (!isMountedRef.current) cap?.unregister?.(capName);
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
const effectiveDisabled = Boolean(disabled) || accessState.disabled;
|
|
96
|
+
const isInteractive = !effectiveDisabled && !accessState.readonly;
|
|
97
|
+
const isFormMode = Boolean(bridge?.register);
|
|
98
|
+
|
|
99
|
+
let resolvedError = error;
|
|
100
|
+
let resolvedHelperText: typeof helperText = helperText;
|
|
101
|
+
let registration: FieldRegistration | null = null;
|
|
102
|
+
let resolvedValue: string | null;
|
|
103
|
+
|
|
104
|
+
if (isFormMode && bridge) {
|
|
105
|
+
registration = bridge.register(name, rules);
|
|
106
|
+
const validation = resolveValidationState(name, bridge, error, helperText);
|
|
107
|
+
resolvedError = validation.error;
|
|
108
|
+
resolvedHelperText = validation.helperText;
|
|
109
|
+
if (explicitValue !== undefined) {
|
|
110
|
+
resolvedValue = explicitValue;
|
|
111
|
+
} else {
|
|
112
|
+
const bv = fieldMeta.value;
|
|
113
|
+
resolvedValue = bv == null ? null : String(bv);
|
|
114
|
+
}
|
|
115
|
+
} else if (explicitValue !== undefined) {
|
|
116
|
+
resolvedValue = explicitValue;
|
|
117
|
+
} else {
|
|
118
|
+
resolvedValue = internalValue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const registrationRefFn = registration?.ref;
|
|
122
|
+
const triggerRef = useCallback(
|
|
123
|
+
(instance: HTMLButtonElement | null) => {
|
|
124
|
+
if (typeof registrationRefFn === 'function') {
|
|
125
|
+
registrationRefFn(instance);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
[registrationRefFn],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (!isVisible) return null;
|
|
132
|
+
if (!accessState.visible) return null;
|
|
133
|
+
|
|
134
|
+
const v = timePickerVariants({ layout, error: resolvedError, fullWidth });
|
|
135
|
+
const displayValue =
|
|
136
|
+
resolvedValue != null && resolvedValue !== ''
|
|
137
|
+
? formatTime(resolvedValue, { hour12 })
|
|
138
|
+
: '';
|
|
139
|
+
|
|
140
|
+
const options = generateTimeOptions({
|
|
141
|
+
...(minTime !== undefined && { start: minTime }),
|
|
142
|
+
...(maxTime !== undefined && { end: maxTime }),
|
|
143
|
+
...(stepMinutes !== undefined && { stepMinutes }),
|
|
144
|
+
});
|
|
145
|
+
const minMinutes = timeStringToMinutes(minTime ?? '00:00') ?? 0;
|
|
146
|
+
const maxMinutes = timeStringToMinutes(maxTime ?? '23:59') ?? 1439;
|
|
147
|
+
const isTimeDisabled = (time: string): boolean => {
|
|
148
|
+
const minutes = timeStringToMinutes(time);
|
|
149
|
+
return minutes === null || minutes < minMinutes || minutes > maxMinutes;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const commitValue = (next: string | null) => {
|
|
153
|
+
if (isFormMode && bridge) {
|
|
154
|
+
bridge.setValue?.(name, next);
|
|
155
|
+
void registration?.onChange?.({
|
|
156
|
+
target: { name, value: next ?? '' },
|
|
157
|
+
type: 'change',
|
|
158
|
+
});
|
|
159
|
+
} else if (explicitValue === undefined) {
|
|
160
|
+
setInternalValue(next);
|
|
161
|
+
}
|
|
162
|
+
onChange?.(next);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const markTouched = () => {
|
|
166
|
+
if (isFormMode && bridge) {
|
|
167
|
+
const committed = bridge.getValue(name);
|
|
168
|
+
void registration?.onBlur?.({
|
|
169
|
+
target: { name, value: committed == null ? '' : String(committed) },
|
|
170
|
+
type: 'blur',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const handleOpenChange = (next: boolean) => {
|
|
176
|
+
if (next && !isInteractive) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
setIsOpen(next);
|
|
180
|
+
if (!next) {
|
|
181
|
+
markTouched();
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const handleSelect = (time: string) => {
|
|
186
|
+
if (isTimeDisabled(time)) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
commitValue(time);
|
|
190
|
+
setIsOpen(false);
|
|
191
|
+
markTouched();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div
|
|
196
|
+
data-testid={testId}
|
|
197
|
+
className={cn(v.root(), sx, slotProps?.root?.className)}
|
|
198
|
+
>
|
|
199
|
+
{label && (
|
|
200
|
+
<label
|
|
201
|
+
htmlFor={fieldId}
|
|
202
|
+
className={cn(v.label(), slotProps?.label?.className)}
|
|
203
|
+
>
|
|
204
|
+
{label}
|
|
205
|
+
{required && (
|
|
206
|
+
<span
|
|
207
|
+
aria-hidden="true"
|
|
208
|
+
className={cn(v.requiredMark(), slotProps?.requiredMark?.className)}
|
|
209
|
+
>
|
|
210
|
+
*
|
|
211
|
+
</span>
|
|
212
|
+
)}
|
|
213
|
+
</label>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
<Popover
|
|
217
|
+
open={isOpen}
|
|
218
|
+
onOpenChange={handleOpenChange}
|
|
219
|
+
side="bottom"
|
|
220
|
+
align="start"
|
|
221
|
+
content={
|
|
222
|
+
<div role="listbox" aria-label="Time options" className={v.list()}>
|
|
223
|
+
{options.map((time) => (
|
|
224
|
+
<button
|
|
225
|
+
key={time}
|
|
226
|
+
type="button"
|
|
227
|
+
role="option"
|
|
228
|
+
aria-selected={time === resolvedValue}
|
|
229
|
+
aria-disabled={isTimeDisabled(time)}
|
|
230
|
+
className={v.option()}
|
|
231
|
+
onClick={() => {
|
|
232
|
+
handleSelect(time);
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
{formatTime(time, { hour12 })}
|
|
236
|
+
</button>
|
|
237
|
+
))}
|
|
238
|
+
</div>
|
|
239
|
+
}
|
|
240
|
+
>
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
id={fieldId}
|
|
244
|
+
ref={triggerRef}
|
|
245
|
+
disabled={effectiveDisabled}
|
|
246
|
+
aria-invalid={resolvedError ? true : undefined}
|
|
247
|
+
aria-describedby={resolvedHelperText ? helperId : undefined}
|
|
248
|
+
className={cn(v.trigger(), slotProps?.trigger?.className)}
|
|
249
|
+
>
|
|
250
|
+
<span className={displayValue ? v.value() : v.placeholder()}>
|
|
251
|
+
{displayValue || placeholder || 'Select a time'}
|
|
252
|
+
</span>
|
|
253
|
+
<span className={v.icon()}>
|
|
254
|
+
<ClockIcon />
|
|
255
|
+
</span>
|
|
256
|
+
</button>
|
|
257
|
+
</Popover>
|
|
258
|
+
|
|
259
|
+
{resolvedHelperText && (
|
|
260
|
+
<p
|
|
261
|
+
id={helperId}
|
|
262
|
+
className={cn(
|
|
263
|
+
resolvedError ? v.errorText() : v.helperText(),
|
|
264
|
+
resolvedError
|
|
265
|
+
? slotProps?.errorText?.className
|
|
266
|
+
: slotProps?.helperText?.className,
|
|
267
|
+
)}
|
|
268
|
+
>
|
|
269
|
+
{resolvedHelperText}
|
|
270
|
+
</p>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { Engine } from '@dashforge/ui-core';
|
|
3
|
+
import type { AccessRequirement } from '@dashforge/rbac';
|
|
4
|
+
|
|
5
|
+
/** Per-slot `className` overrides for `<TimePicker>`. */
|
|
6
|
+
export interface TimePickerSlotProps {
|
|
7
|
+
root?: { className?: string };
|
|
8
|
+
label?: { className?: string };
|
|
9
|
+
requiredMark?: { className?: string };
|
|
10
|
+
trigger?: { className?: string };
|
|
11
|
+
helperText?: { className?: string };
|
|
12
|
+
errorText?: { className?: string };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Props for the `<TimePicker>` form field (Tailwind skin).
|
|
17
|
+
*
|
|
18
|
+
* A read-only trigger paired with a `<Calendar>`-style time-list popover.
|
|
19
|
+
* The stored value is a canonical 24-hour `"HH:mm"` string, or `null`.
|
|
20
|
+
* 12-hour notation (`hour12`) is display-only. No date, no timezone.
|
|
21
|
+
*
|
|
22
|
+
* The prop surface mirrors the MUI `@dashforge/ui` `TimePicker`.
|
|
23
|
+
*/
|
|
24
|
+
export interface TimePickerProps {
|
|
25
|
+
/** Field name — the bridge registration key. Required. */
|
|
26
|
+
name: string;
|
|
27
|
+
/** Validation rules forwarded to the form bridge. */
|
|
28
|
+
rules?: unknown;
|
|
29
|
+
/** Field label. */
|
|
30
|
+
label?: ReactNode;
|
|
31
|
+
/** Helper text below the control (overrides a bridge error message). */
|
|
32
|
+
helperText?: ReactNode;
|
|
33
|
+
/** Explicit error state (overrides the bridge's auto error). */
|
|
34
|
+
error?: boolean;
|
|
35
|
+
/** Marks the field required (adds the label asterisk). */
|
|
36
|
+
required?: boolean;
|
|
37
|
+
/** Disables the field. */
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
/** Placeholder shown when no time is selected. */
|
|
40
|
+
placeholder?: string;
|
|
41
|
+
/** Label/control layout. */
|
|
42
|
+
layout?: 'stacked' | 'inline';
|
|
43
|
+
/** Reactive visibility predicate evaluated against the form engine. */
|
|
44
|
+
visibleWhen?: (engine: Engine) => boolean;
|
|
45
|
+
/** RBAC access requirement. */
|
|
46
|
+
access?: AccessRequirement;
|
|
47
|
+
/** Controlled value — `"HH:mm"` (24-hour) or `null`. */
|
|
48
|
+
value?: string | null;
|
|
49
|
+
/** Uncontrolled initial value. */
|
|
50
|
+
defaultValue?: string | null;
|
|
51
|
+
/** Fired with the new time (or `null` when cleared). */
|
|
52
|
+
onChange?: (value: string | null) => void;
|
|
53
|
+
/** Earliest selectable time, `"HH:mm"`. Default `"00:00"`. */
|
|
54
|
+
minTime?: string;
|
|
55
|
+
/** Latest selectable time, `"HH:mm"`. Default `"23:30"`. */
|
|
56
|
+
maxTime?: string;
|
|
57
|
+
/** Step between time-list options, in minutes. Default `30`. */
|
|
58
|
+
stepMinutes?: number;
|
|
59
|
+
/** Render the time list in 12-hour notation. Default `false`. */
|
|
60
|
+
hour12?: boolean;
|
|
61
|
+
/** Stretches the field to its container width. */
|
|
62
|
+
fullWidth?: boolean;
|
|
63
|
+
/** Root-level Tailwind class override. */
|
|
64
|
+
sx?: string;
|
|
65
|
+
/** Per-slot `className` overrides. */
|
|
66
|
+
slotProps?: TimePickerSlotProps;
|
|
67
|
+
/** Test id applied to the field root. */
|
|
68
|
+
testId?: string;
|
|
69
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { tv } from 'tailwind-variants';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Slots for `<TimePicker>` — a bridge-integrated time field whose trigger
|
|
5
|
+
* is a `<button>` styled as an input, opening a time-list popover.
|
|
6
|
+
*
|
|
7
|
+
* Theme identity: bare neutral / brand token-backed classes only — no
|
|
8
|
+
* `dark:` variants on the neutral palette (the preset CSS-var swap
|
|
9
|
+
* auto-inverts). See `_shared/themeIdentity.test.ts`.
|
|
10
|
+
*/
|
|
11
|
+
export const timePickerVariants = tv({
|
|
12
|
+
slots: {
|
|
13
|
+
root: 'flex flex-col',
|
|
14
|
+
label: 'block text-sm font-medium text-neutral-900 mb-1',
|
|
15
|
+
requiredMark: 'text-danger-500 ml-0.5',
|
|
16
|
+
trigger:
|
|
17
|
+
'flex items-center gap-2 h-10 min-w-[10rem] rounded-md border border-neutral-300 bg-neutral-50 px-3 text-base text-neutral-900 cursor-pointer transition-colors hover:border-neutral-400 focus-visible:outline-none focus-visible:border-primary-500 focus-visible:ring-2 focus-visible:ring-primary-500/30 disabled:cursor-not-allowed disabled:opacity-60 disabled:bg-neutral-200',
|
|
18
|
+
value: 'flex-1 truncate text-left',
|
|
19
|
+
placeholder: 'flex-1 truncate text-left text-neutral-400',
|
|
20
|
+
icon: 'shrink-0 text-[1rem] text-neutral-500',
|
|
21
|
+
list: 'flex max-h-64 min-w-[8rem] flex-col gap-0.5 overflow-y-auto p-1',
|
|
22
|
+
option:
|
|
23
|
+
'flex w-full cursor-pointer items-center rounded px-2.5 py-1.5 text-sm text-neutral-900 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500 aria-selected:bg-primary-600 aria-selected:text-white aria-selected:hover:bg-primary-600 aria-disabled:cursor-default aria-disabled:text-neutral-300 aria-disabled:opacity-60 aria-disabled:hover:bg-transparent',
|
|
24
|
+
helperText: 'mt-1 text-sm text-neutral-600',
|
|
25
|
+
errorText: 'mt-1 text-sm text-danger-600',
|
|
26
|
+
},
|
|
27
|
+
variants: {
|
|
28
|
+
layout: {
|
|
29
|
+
stacked: {},
|
|
30
|
+
inline: {
|
|
31
|
+
root: 'flex-row items-start gap-3',
|
|
32
|
+
label: 'mb-0 pt-2.5 whitespace-nowrap shrink-0',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
error: {
|
|
36
|
+
true: {
|
|
37
|
+
trigger:
|
|
38
|
+
'border-danger-500 hover:border-danger-500 focus-visible:border-danger-500 focus-visible:ring-danger-500/30',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
fullWidth: {
|
|
42
|
+
true: {
|
|
43
|
+
root: 'w-full',
|
|
44
|
+
trigger: 'w-full min-w-0',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
defaultVariants: {
|
|
49
|
+
layout: 'stacked',
|
|
50
|
+
error: false,
|
|
51
|
+
fullWidth: false,
|
|
52
|
+
},
|
|
53
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -95,21 +95,9 @@ export type {
|
|
|
95
95
|
} from './components/Autocomplete/autocomplete.types.js';
|
|
96
96
|
export { autocompleteVariants } from './components/Autocomplete/autocomplete.variants.js';
|
|
97
97
|
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
DateTimePicker,
|
|
102
|
-
isoToInputValue,
|
|
103
|
-
} from './components/DateTimePicker/DateTimePicker.js';
|
|
104
|
-
export type {
|
|
105
|
-
DateTimePickerProps,
|
|
106
|
-
DateTimePickerMode,
|
|
107
|
-
DateTimePickerSlotProps,
|
|
108
|
-
} from './components/DateTimePicker/dateTimePicker.types.js';
|
|
109
|
-
export { dateTimePickerVariants } from './components/DateTimePicker/dateTimePicker.variants.js';
|
|
110
|
-
|
|
111
|
-
// Custom calendar suite — Calendar primitive + bridge-integrated DatePicker,
|
|
112
|
-
// both built on the shared headless `@dashforge/calendar-core` engine.
|
|
98
|
+
// Custom calendar suite — Calendar primitive + bridge-integrated DatePicker /
|
|
99
|
+
// TimePicker / DateTimePicker / DateRangePicker, all built on the shared
|
|
100
|
+
// headless `@dashforge/calendar-core` engine.
|
|
113
101
|
|
|
114
102
|
export { Calendar } from './components/Calendar/Calendar.js';
|
|
115
103
|
export type {
|
|
@@ -128,6 +116,30 @@ export type {
|
|
|
128
116
|
} from './components/DatePicker/datePicker.types.js';
|
|
129
117
|
export { datePickerVariants } from './components/DatePicker/datePicker.variants.js';
|
|
130
118
|
|
|
119
|
+
export { TimePicker } from './components/TimePicker/TimePicker.js';
|
|
120
|
+
export type {
|
|
121
|
+
TimePickerProps,
|
|
122
|
+
TimePickerSlotProps,
|
|
123
|
+
} from './components/TimePicker/timePicker.types.js';
|
|
124
|
+
export { timePickerVariants } from './components/TimePicker/timePicker.variants.js';
|
|
125
|
+
|
|
126
|
+
export { DateTimePicker } from './components/DateTimePicker/DateTimePicker.js';
|
|
127
|
+
export type {
|
|
128
|
+
DateTimePickerProps,
|
|
129
|
+
DateTimePickerSlotProps,
|
|
130
|
+
} from './components/DateTimePicker/dateTimePicker.types.js';
|
|
131
|
+
export { dateTimePickerVariants } from './components/DateTimePicker/dateTimePicker.variants.js';
|
|
132
|
+
|
|
133
|
+
export { DateRangePicker } from './components/DateRangePicker/DateRangePicker.js';
|
|
134
|
+
export type {
|
|
135
|
+
DateRangePickerProps,
|
|
136
|
+
DateRangePickerSlotProps,
|
|
137
|
+
} from './components/DateRangePicker/dateRangePicker.types.js';
|
|
138
|
+
export {
|
|
139
|
+
dateRangePickerVariants,
|
|
140
|
+
dateRangeDayVariants,
|
|
141
|
+
} from './components/DateRangePicker/dateRangePicker.variants.js';
|
|
142
|
+
|
|
131
143
|
// F6 layout: router-agnostic navigation primitives.
|
|
132
144
|
|
|
133
145
|
export { Breadcrumbs } from './components/Breadcrumbs/Breadcrumbs.js';
|
|
@@ -373,4 +385,4 @@ export type { VariantProps } from 'tailwind-variants';
|
|
|
373
385
|
/**
|
|
374
386
|
* Package version (synced with `package.json` at publish time).
|
|
375
387
|
*/
|
|
376
|
-
export const VERSION = '0.
|
|
388
|
+
export const VERSION = '0.11.0-beta';
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* Performance + re-render budget tests for `<DateTimePicker>`.
|
|
4
|
-
*
|
|
5
|
-
* Guardrails — not absolute benchmarks. They protect against:
|
|
6
|
-
*
|
|
7
|
-
* 1. Pathological mount cost (an accidental quadratic in a future
|
|
8
|
-
* refactor — much less likely here than in `<Autocomplete>` because
|
|
9
|
-
* there's no option list, but the test still pins the budget).
|
|
10
|
-
*
|
|
11
|
-
* 2. Sibling re-renders on every keystroke. The bridge subscription
|
|
12
|
-
* model (`useDashFieldMeta(name)`) must remain GRANULAR — only the
|
|
13
|
-
* field whose value changed re-renders, never a sibling.
|
|
14
|
-
*
|
|
15
|
-
* 3. Linear render budget per keystroke for THIS field. Aim for 1
|
|
16
|
-
* render per `onChange`; tolerate 2 (RHF / engine reactive snapshot
|
|
17
|
-
* batching).
|
|
18
|
-
*
|
|
19
|
-
* 4. Mode switch isn't accidentally expensive (cycling between
|
|
20
|
-
* `date` / `time` / `datetime` mounts a NEW native input each time
|
|
21
|
-
* — but the component itself must not allocate per-key arrays etc.).
|
|
22
|
-
*/
|
|
23
|
-
import { describe, it, expect } from 'vitest';
|
|
24
|
-
import { render, fireEvent, screen } from '@testing-library/react';
|
|
25
|
-
import { DashFormProvider } from '@dashforge/forms';
|
|
26
|
-
import { DateTimePicker, TextField } from '../../index.js';
|
|
27
|
-
|
|
28
|
-
function withRenderCounter<P>(
|
|
29
|
-
Component: React.ComponentType<P>,
|
|
30
|
-
counterRef: { count: number }
|
|
31
|
-
): React.FC<P> {
|
|
32
|
-
return function Counted(props: P) {
|
|
33
|
-
counterRef.count++;
|
|
34
|
-
return <Component {...(props as P & object)} />;
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
describe('<DateTimePicker> performance', () => {
|
|
39
|
-
it('mounts well under 50ms (standalone, no bridge)', () => {
|
|
40
|
-
const t0 = performance.now();
|
|
41
|
-
render(<DateTimePicker name="d" label="Date" />);
|
|
42
|
-
const t1 = performance.now();
|
|
43
|
-
expect(t1 - t0).toBeLessThan(50);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('mounts under 100ms inside DashFormProvider (with bridge wiring)', () => {
|
|
47
|
-
const t0 = performance.now();
|
|
48
|
-
render(
|
|
49
|
-
<DashFormProvider defaultValues={{ d: '2026-05-16' }}>
|
|
50
|
-
<DateTimePicker name="d" label="Date" />
|
|
51
|
-
</DashFormProvider>
|
|
52
|
-
);
|
|
53
|
-
const t1 = performance.now();
|
|
54
|
-
expect(t1 - t0).toBeLessThan(100);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('processes a value change in under 20ms', () => {
|
|
58
|
-
render(<DateTimePicker name="d" label="Date" />);
|
|
59
|
-
const input = screen.getByLabelText('Date') as HTMLInputElement;
|
|
60
|
-
const t0 = performance.now();
|
|
61
|
-
fireEvent.change(input, { target: { value: '2026-12-25' } });
|
|
62
|
-
const t1 = performance.now();
|
|
63
|
-
expect(t1 - t0).toBeLessThan(20);
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe('<DateTimePicker> re-render budget', () => {
|
|
68
|
-
it('renders at most 2 times on mount', () => {
|
|
69
|
-
const counter = { count: 0 };
|
|
70
|
-
const Counted = withRenderCounter(DateTimePicker, counter);
|
|
71
|
-
render(<Counted name="d" label="Date" />);
|
|
72
|
-
// 1 initial + (tolerate 1 follow-up commit for any setState
|
|
73
|
-
// scheduled in a useEffect; today we don't schedule any, but the
|
|
74
|
-
// budget allows headroom).
|
|
75
|
-
expect(counter.count).toBeLessThanOrEqual(2);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('keeps controlled re-renders ≤ 1 per value prop change', () => {
|
|
79
|
-
const counter = { count: 0 };
|
|
80
|
-
const Counted = withRenderCounter(DateTimePicker, counter);
|
|
81
|
-
const { rerender } = render(
|
|
82
|
-
<Counted name="d" label="Date" value="2026-01-01" />
|
|
83
|
-
);
|
|
84
|
-
const baseline = counter.count;
|
|
85
|
-
rerender(<Counted name="d" label="Date" value="2026-01-02" />);
|
|
86
|
-
rerender(<Counted name="d" label="Date" value="2026-01-03" />);
|
|
87
|
-
rerender(<Counted name="d" label="Date" value="2026-01-04" />);
|
|
88
|
-
// 3 prop changes ⇒ ≤ 4 renders (one per change + tolerance).
|
|
89
|
-
expect(counter.count - baseline).toBeLessThanOrEqual(4);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('does NOT re-render an unrelated sibling on each value change', () => {
|
|
93
|
-
// Critical perf property: typing into one DateTimePicker must not
|
|
94
|
-
// cause sibling components to re-render. Granular per-field
|
|
95
|
-
// subscription via `useDashFieldMeta(name)` is what enforces this.
|
|
96
|
-
const sibCount = { count: 0 };
|
|
97
|
-
const Sibling = () => {
|
|
98
|
-
sibCount.count++;
|
|
99
|
-
return <div data-testid="sib" />;
|
|
100
|
-
};
|
|
101
|
-
render(
|
|
102
|
-
<DashFormProvider defaultValues={{ d: '2026-01-01', other: 'x' }}>
|
|
103
|
-
<DateTimePicker name="d" label="Date" />
|
|
104
|
-
<Sibling />
|
|
105
|
-
</DashFormProvider>
|
|
106
|
-
);
|
|
107
|
-
const before = sibCount.count;
|
|
108
|
-
const input = screen.getByLabelText('Date') as HTMLInputElement;
|
|
109
|
-
for (let i = 1; i <= 5; i++) {
|
|
110
|
-
fireEvent.change(input, {
|
|
111
|
-
target: { value: `2026-01-0${i + 1}` },
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
expect(sibCount.count).toBe(before);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('does NOT re-render when a sibling field changes (per-field subscription)', () => {
|
|
118
|
-
// Inverse of the above: changing the OTHER field in the bridge
|
|
119
|
-
// must not re-render the DateTimePicker. Verifies the granular
|
|
120
|
-
// `useDashFieldMeta` subscription is filtering by name.
|
|
121
|
-
const dtCount = { count: 0 };
|
|
122
|
-
const Counted = withRenderCounter(DateTimePicker, dtCount);
|
|
123
|
-
render(
|
|
124
|
-
<DashFormProvider defaultValues={{ d: '2026-01-01', other: '' }}>
|
|
125
|
-
<Counted name="d" label="Date" />
|
|
126
|
-
<TextField name="other" label="Other" />
|
|
127
|
-
</DashFormProvider>
|
|
128
|
-
);
|
|
129
|
-
const baseline = dtCount.count;
|
|
130
|
-
const other = screen.getByLabelText('Other') as HTMLInputElement;
|
|
131
|
-
for (let i = 0; i < 5; i++) {
|
|
132
|
-
fireEvent.change(other, { target: { value: 'a'.repeat(i + 1) } });
|
|
133
|
-
}
|
|
134
|
-
// The DateTimePicker may re-render once on initial mount of the
|
|
135
|
-
// sibling (parent re-render propagation) but NOT on each sibling
|
|
136
|
-
// keystroke. Budget: 0 additional renders.
|
|
137
|
-
expect(dtCount.count - baseline).toBeLessThanOrEqual(1);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('mode switch (date ⇄ datetime) does not leak the previous native input', () => {
|
|
141
|
-
// Switching `mode` changes the native element's `type` attribute —
|
|
142
|
-
// React reuses the same DOM node (no remount). Assert that the
|
|
143
|
-
// input element identity is preserved.
|
|
144
|
-
const { rerender, container } = render(
|
|
145
|
-
<DateTimePicker name="d" label="X" mode="date" />
|
|
146
|
-
);
|
|
147
|
-
const inputBefore = container.querySelector('input');
|
|
148
|
-
rerender(<DateTimePicker name="d" label="X" mode="datetime" />);
|
|
149
|
-
const inputAfter = container.querySelector('input');
|
|
150
|
-
expect(inputBefore).toBe(inputAfter);
|
|
151
|
-
expect((inputAfter as HTMLInputElement).type).toBe('datetime-local');
|
|
152
|
-
});
|
|
153
|
-
});
|