@dashforge/tw 0.9.1-beta → 0.10.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 +31 -0
- package/dist/index.esm.js +1152 -336
- 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/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 +4 -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/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,84 +1,55 @@
|
|
|
1
|
-
import { tv
|
|
1
|
+
import { tv } from 'tailwind-variants';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Slots for `<DateTimePicker>` — a bridge-integrated date+time field whose
|
|
5
|
+
* trigger is a `<button>` styled as an input, opening a popover that
|
|
6
|
+
* combines a `<Calendar>` with a time list.
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Native `<input type="date|time|datetime-local">` quirks handled here:
|
|
10
|
-
*
|
|
11
|
-
* - The browser-rendered placeholder mask (e.g., `mm/dd/yyyy`) is
|
|
12
|
-
* invisible on `bg-neutral-50` by default in some themes; we keep
|
|
13
|
-
* the same neutral palette and rely on the input's own contrast
|
|
14
|
-
* rules (no extra `::placeholder` styling — browsers don't honor it
|
|
15
|
-
* on these input types).
|
|
16
|
-
* - The native picker indicator (calendar / clock icon) is
|
|
17
|
-
* browser-supplied; we ensure it stays visible in dark mode via
|
|
18
|
-
* `color-scheme: light dark` on the input (set inline below).
|
|
8
|
+
* Theme identity: bare neutral / brand token-backed classes only — no
|
|
9
|
+
* `dark:` variants on the neutral palette (the preset CSS-var swap
|
|
10
|
+
* auto-inverts). See `_shared/themeIdentity.test.ts`.
|
|
19
11
|
*/
|
|
20
12
|
export const dateTimePickerVariants = tv({
|
|
21
13
|
slots: {
|
|
22
|
-
root: 'flex',
|
|
14
|
+
root: 'flex flex-col',
|
|
23
15
|
label: 'block text-sm font-medium text-neutral-900 mb-1',
|
|
24
16
|
requiredMark: 'text-danger-500 ml-0.5',
|
|
25
|
-
|
|
26
|
-
'
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
],
|
|
32
|
-
|
|
33
|
-
'
|
|
34
|
-
'text-neutral-900 placeholder:text-neutral-400',
|
|
35
|
-
'disabled:cursor-not-allowed disabled:opacity-60',
|
|
36
|
-
'read-only:cursor-default',
|
|
37
|
-
// `color-scheme` lets the native calendar / clock icon respect
|
|
38
|
-
// the dark mode (otherwise it stays light-themed against dark bg)
|
|
39
|
-
'[color-scheme:light_dark]',
|
|
40
|
-
],
|
|
17
|
+
trigger:
|
|
18
|
+
'flex items-center gap-2 h-10 min-w-[13rem] 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',
|
|
19
|
+
value: 'flex-1 truncate text-left',
|
|
20
|
+
placeholder: 'flex-1 truncate text-left text-neutral-400',
|
|
21
|
+
icon: 'shrink-0 text-[1rem] text-neutral-500',
|
|
22
|
+
panel: 'flex items-stretch',
|
|
23
|
+
list: 'flex max-h-[19rem] min-w-[6.5rem] flex-col gap-0.5 overflow-y-auto border-l border-neutral-200 p-1',
|
|
24
|
+
option:
|
|
25
|
+
'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',
|
|
41
26
|
helperText: 'mt-1 text-sm text-neutral-600',
|
|
42
27
|
errorText: 'mt-1 text-sm text-danger-600',
|
|
43
28
|
},
|
|
44
29
|
variants: {
|
|
45
|
-
size: {
|
|
46
|
-
sm: { inputWrapper: 'h-8 px-2', input: 'text-sm' },
|
|
47
|
-
md: { inputWrapper: 'h-10 px-3', input: 'text-base' },
|
|
48
|
-
lg: { inputWrapper: 'h-12 px-4', input: 'text-lg' },
|
|
49
|
-
},
|
|
50
30
|
layout: {
|
|
51
|
-
stacked: {
|
|
31
|
+
stacked: {},
|
|
52
32
|
inline: {
|
|
53
33
|
root: 'flex-row items-start gap-3',
|
|
54
|
-
label: 'mb-0 pt-2 whitespace-nowrap shrink-0',
|
|
34
|
+
label: 'mb-0 pt-2.5 whitespace-nowrap shrink-0',
|
|
55
35
|
},
|
|
56
36
|
},
|
|
57
37
|
error: {
|
|
58
38
|
true: {
|
|
59
|
-
|
|
60
|
-
'border-danger-500 focus-
|
|
39
|
+
trigger:
|
|
40
|
+
'border-danger-500 hover:border-danger-500 focus-visible:border-danger-500 focus-visible:ring-danger-500/30',
|
|
61
41
|
},
|
|
62
42
|
},
|
|
63
43
|
fullWidth: {
|
|
64
44
|
true: {
|
|
65
45
|
root: 'w-full',
|
|
66
|
-
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
disabled: {
|
|
70
|
-
true: {
|
|
71
|
-
inputWrapper: 'opacity-60 cursor-not-allowed bg-neutral-200',
|
|
46
|
+
trigger: 'w-full min-w-0',
|
|
72
47
|
},
|
|
73
48
|
},
|
|
74
49
|
},
|
|
75
50
|
defaultVariants: {
|
|
76
|
-
size: 'md',
|
|
77
51
|
layout: 'stacked',
|
|
78
52
|
error: false,
|
|
79
53
|
fullWidth: false,
|
|
80
|
-
disabled: false,
|
|
81
54
|
},
|
|
82
55
|
});
|
|
83
|
-
|
|
84
|
-
export type DateTimePickerVariants = VariantProps<typeof dateTimePickerVariants>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
|
|
5
|
+
import { TimePicker } from './TimePicker.js';
|
|
6
|
+
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
// Radix Popover (Floating UI) needs ResizeObserver — jsdom lacks it.
|
|
9
|
+
if (!('ResizeObserver' in globalThis)) {
|
|
10
|
+
class ResizeObserverStub {
|
|
11
|
+
observe(): void {
|
|
12
|
+
/* no-op */
|
|
13
|
+
}
|
|
14
|
+
unobserve(): void {
|
|
15
|
+
/* no-op */
|
|
16
|
+
}
|
|
17
|
+
disconnect(): void {
|
|
18
|
+
/* no-op */
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
(globalThis as { ResizeObserver?: unknown }).ResizeObserver =
|
|
22
|
+
ResizeObserverStub;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('TimePicker', () => {
|
|
27
|
+
it('renders a labelled trigger with the placeholder', () => {
|
|
28
|
+
render(<TimePicker name="alarm" label="Alarm" placeholder="Pick a time" />);
|
|
29
|
+
expect(screen.queryByText('Alarm')).not.toBeNull();
|
|
30
|
+
expect(screen.getByRole('button').textContent).toContain('Pick a time');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('shows a 24-hour controlled value', () => {
|
|
34
|
+
render(<TimePicker name="t" value="14:30" />);
|
|
35
|
+
expect(screen.getByRole('button').textContent).toContain('14:30');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('formats the value in 12-hour notation when hour12 is set', () => {
|
|
39
|
+
render(<TimePicker name="t" value="14:30" hour12 />);
|
|
40
|
+
expect(screen.getByRole('button').textContent).toContain('2:30 PM');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('opens the time-list popover from the trigger', () => {
|
|
44
|
+
render(<TimePicker name="t" />);
|
|
45
|
+
expect(screen.queryByRole('listbox')).toBeNull();
|
|
46
|
+
fireEvent.click(screen.getByRole('button'));
|
|
47
|
+
expect(screen.getByRole('listbox', { name: 'Time options' })).not.toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('selects a time from the popover, updates the trigger and closes', () => {
|
|
51
|
+
const onChange = vi.fn();
|
|
52
|
+
render(<TimePicker name="t" onChange={onChange} />);
|
|
53
|
+
fireEvent.click(screen.getByRole('button'));
|
|
54
|
+
fireEvent.click(screen.getByRole('option', { name: '09:00' }));
|
|
55
|
+
expect(onChange).toHaveBeenCalledWith('09:00');
|
|
56
|
+
expect(screen.queryByRole('listbox')).toBeNull();
|
|
57
|
+
expect(screen.getByRole('button').textContent).toContain('09:00');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('does not open the popover when disabled', () => {
|
|
61
|
+
render(<TimePicker name="t" disabled />);
|
|
62
|
+
fireEvent.click(screen.getByRole('button'));
|
|
63
|
+
expect(screen.queryByRole('listbox')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -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.10.0-beta';
|