@dashforge/tw 0.9.1-beta → 0.11.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/index.esm.js +1330 -405
  3. package/dist/src/components/DateRangePicker/DateRangePicker.d.ts +13 -0
  4. package/dist/src/components/DateRangePicker/DateRangePicker.d.ts.map +1 -0
  5. package/dist/src/components/DateRangePicker/dateRangePicker.types.d.ts +85 -0
  6. package/dist/src/components/DateRangePicker/dateRangePicker.types.d.ts.map +1 -0
  7. package/dist/src/components/DateRangePicker/dateRangePicker.variants.d.ts +183 -0
  8. package/dist/src/components/DateRangePicker/dateRangePicker.variants.d.ts.map +1 -0
  9. package/dist/src/components/DateTimePicker/DateTimePicker.d.ts +7 -43
  10. package/dist/src/components/DateTimePicker/DateTimePicker.d.ts.map +1 -1
  11. package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts +50 -55
  12. package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts.map +1 -1
  13. package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts +36 -94
  14. package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts.map +1 -1
  15. package/dist/src/components/Tabs/Tabs.d.ts +8 -4
  16. package/dist/src/components/Tabs/Tabs.d.ts.map +1 -1
  17. package/dist/src/components/Tabs/tabs.types.d.ts +8 -2
  18. package/dist/src/components/Tabs/tabs.types.d.ts.map +1 -1
  19. package/dist/src/components/Tabs/useTabs.d.ts +55 -0
  20. package/dist/src/components/Tabs/useTabs.d.ts.map +1 -0
  21. package/dist/src/components/TimePicker/TimePicker.d.ts +12 -0
  22. package/dist/src/components/TimePicker/TimePicker.d.ts.map +1 -0
  23. package/dist/src/components/TimePicker/timePicker.types.d.ts +80 -0
  24. package/dist/src/components/TimePicker/timePicker.types.d.ts.map +1 -0
  25. package/dist/src/components/TimePicker/timePicker.variants.d.ts +103 -0
  26. package/dist/src/components/TimePicker/timePicker.variants.d.ts.map +1 -0
  27. package/dist/src/index.d.ts +10 -4
  28. package/dist/src/index.d.ts.map +1 -1
  29. package/package.json +3 -4
  30. package/src/components/DateRangePicker/DateRangePicker.test.tsx +92 -0
  31. package/src/components/DateRangePicker/DateRangePicker.tsx +510 -0
  32. package/src/components/DateRangePicker/dateRangePicker.types.ts +74 -0
  33. package/src/components/DateRangePicker/dateRangePicker.variants.ts +101 -0
  34. package/src/components/DateTimePicker/DateTimePicker.test.tsx +60 -186
  35. package/src/components/DateTimePicker/DateTimePicker.tsx +209 -181
  36. package/src/components/DateTimePicker/dateTimePicker.types.ts +50 -54
  37. package/src/components/DateTimePicker/dateTimePicker.variants.ts +22 -51
  38. package/src/components/Tabs/Tabs.test.tsx +85 -14
  39. package/src/components/Tabs/Tabs.tsx +35 -24
  40. package/src/components/Tabs/tabs.types.ts +8 -2
  41. package/src/components/Tabs/useTabs.ts +167 -0
  42. package/src/components/TimePicker/TimePicker.test.tsx +65 -0
  43. package/src/components/TimePicker/TimePicker.tsx +274 -0
  44. package/src/components/TimePicker/timePicker.types.ts +69 -0
  45. package/src/components/TimePicker/timePicker.variants.ts +53 -0
  46. package/src/index.ts +28 -16
  47. package/src/components/DateTimePicker/DateTimePicker.perf.test.tsx +0 -153
@@ -0,0 +1,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
- // F5-B tier-3: native HTML5 date / time / datetime picker, bridge-integrated.
99
-
100
- export {
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.9.1-beta';
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
- });