@dashforge/tw 0.9.0-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 +60 -0
- package/dist/index.esm.js +1634 -256
- package/dist/src/components/Calendar/Calendar.d.ts +14 -0
- package/dist/src/components/Calendar/Calendar.d.ts.map +1 -0
- package/dist/src/components/Calendar/calendar.types.d.ts +72 -0
- package/dist/src/components/Calendar/calendar.types.d.ts.map +1 -0
- package/dist/src/components/Calendar/calendar.variants.d.ts +99 -0
- package/dist/src/components/Calendar/calendar.variants.d.ts.map +1 -0
- package/dist/src/components/DatePicker/DatePicker.d.ts +14 -0
- package/dist/src/components/DatePicker/DatePicker.d.ts.map +1 -0
- package/dist/src/components/DatePicker/datePicker.types.d.ts +88 -0
- package/dist/src/components/DatePicker/datePicker.types.d.ts.map +1 -0
- package/dist/src/components/DatePicker/datePicker.variants.d.ts +97 -0
- package/dist/src/components/DatePicker/datePicker.variants.d.ts.map +1 -0
- 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 +15 -3
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/components/Calendar/Calendar.test.tsx +75 -0
- package/src/components/Calendar/Calendar.tsx +264 -0
- package/src/components/Calendar/calendar.types.ts +65 -0
- package/src/components/Calendar/calendar.variants.ts +64 -0
- package/src/components/DatePicker/DatePicker.test.tsx +76 -0
- package/src/components/DatePicker/DatePicker.tsx +305 -0
- package/src/components/DatePicker/datePicker.types.ts +77 -0
- package/src/components/DatePicker/datePicker.variants.ts +50 -0
- 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 +38 -6
- package/tsconfig.lib.json +7 -4
- 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,19 +95,51 @@ export type {
|
|
|
95
95
|
} from './components/Autocomplete/autocomplete.types.js';
|
|
96
96
|
export { autocompleteVariants } from './components/Autocomplete/autocomplete.variants.js';
|
|
97
97
|
|
|
98
|
-
//
|
|
98
|
+
// Custom calendar suite — Calendar primitive + bridge-integrated DatePicker /
|
|
99
|
+
// TimePicker / DateTimePicker / DateRangePicker, all built on the shared
|
|
100
|
+
// headless `@dashforge/calendar-core` engine.
|
|
99
101
|
|
|
102
|
+
export { Calendar } from './components/Calendar/Calendar.js';
|
|
103
|
+
export type {
|
|
104
|
+
CalendarProps,
|
|
105
|
+
CalendarSlotProps,
|
|
106
|
+
} from './components/Calendar/calendar.types.js';
|
|
100
107
|
export {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
} from './components/
|
|
108
|
+
calendarVariants,
|
|
109
|
+
calendarDayVariants,
|
|
110
|
+
} from './components/Calendar/calendar.variants.js';
|
|
111
|
+
|
|
112
|
+
export { DatePicker } from './components/DatePicker/DatePicker.js';
|
|
113
|
+
export type {
|
|
114
|
+
DatePickerProps,
|
|
115
|
+
DatePickerSlotProps,
|
|
116
|
+
} from './components/DatePicker/datePicker.types.js';
|
|
117
|
+
export { datePickerVariants } from './components/DatePicker/datePicker.variants.js';
|
|
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';
|
|
104
127
|
export type {
|
|
105
128
|
DateTimePickerProps,
|
|
106
|
-
DateTimePickerMode,
|
|
107
129
|
DateTimePickerSlotProps,
|
|
108
130
|
} from './components/DateTimePicker/dateTimePicker.types.js';
|
|
109
131
|
export { dateTimePickerVariants } from './components/DateTimePicker/dateTimePicker.variants.js';
|
|
110
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
|
+
|
|
111
143
|
// F6 layout: router-agnostic navigation primitives.
|
|
112
144
|
|
|
113
145
|
export { Breadcrumbs } from './components/Breadcrumbs/Breadcrumbs.js';
|
|
@@ -353,4 +385,4 @@ export type { VariantProps } from 'tailwind-variants';
|
|
|
353
385
|
/**
|
|
354
386
|
* Package version (synced with `package.json` at publish time).
|
|
355
387
|
*/
|
|
356
|
-
export const VERSION = '0.
|
|
388
|
+
export const VERSION = '0.10.0-beta';
|
package/tsconfig.lib.json
CHANGED
|
@@ -40,16 +40,19 @@
|
|
|
40
40
|
"path": "../ui-core/tsconfig.lib.json"
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
|
-
"path": "../
|
|
43
|
+
"path": "../rbac/tsconfig.lib.json"
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
|
-
"path": "../
|
|
46
|
+
"path": "../forms/tsconfig.lib.json"
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
|
-
"path": "../
|
|
49
|
+
"path": "../calendar-core/tsconfig.lib.json"
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
|
-
"path": "../
|
|
52
|
+
"path": "../tw-tokens/tsconfig.lib.json"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "../tw-theme/tsconfig.lib.json"
|
|
53
56
|
}
|
|
54
57
|
]
|
|
55
58
|
}
|