@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/index.esm.js +1634 -256
  3. package/dist/src/components/Calendar/Calendar.d.ts +14 -0
  4. package/dist/src/components/Calendar/Calendar.d.ts.map +1 -0
  5. package/dist/src/components/Calendar/calendar.types.d.ts +72 -0
  6. package/dist/src/components/Calendar/calendar.types.d.ts.map +1 -0
  7. package/dist/src/components/Calendar/calendar.variants.d.ts +99 -0
  8. package/dist/src/components/Calendar/calendar.variants.d.ts.map +1 -0
  9. package/dist/src/components/DatePicker/DatePicker.d.ts +14 -0
  10. package/dist/src/components/DatePicker/DatePicker.d.ts.map +1 -0
  11. package/dist/src/components/DatePicker/datePicker.types.d.ts +88 -0
  12. package/dist/src/components/DatePicker/datePicker.types.d.ts.map +1 -0
  13. package/dist/src/components/DatePicker/datePicker.variants.d.ts +97 -0
  14. package/dist/src/components/DatePicker/datePicker.variants.d.ts.map +1 -0
  15. package/dist/src/components/DateRangePicker/DateRangePicker.d.ts +13 -0
  16. package/dist/src/components/DateRangePicker/DateRangePicker.d.ts.map +1 -0
  17. package/dist/src/components/DateRangePicker/dateRangePicker.types.d.ts +85 -0
  18. package/dist/src/components/DateRangePicker/dateRangePicker.types.d.ts.map +1 -0
  19. package/dist/src/components/DateRangePicker/dateRangePicker.variants.d.ts +183 -0
  20. package/dist/src/components/DateRangePicker/dateRangePicker.variants.d.ts.map +1 -0
  21. package/dist/src/components/DateTimePicker/DateTimePicker.d.ts +7 -43
  22. package/dist/src/components/DateTimePicker/DateTimePicker.d.ts.map +1 -1
  23. package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts +50 -55
  24. package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts.map +1 -1
  25. package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts +36 -94
  26. package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts.map +1 -1
  27. package/dist/src/components/TimePicker/TimePicker.d.ts +12 -0
  28. package/dist/src/components/TimePicker/TimePicker.d.ts.map +1 -0
  29. package/dist/src/components/TimePicker/timePicker.types.d.ts +80 -0
  30. package/dist/src/components/TimePicker/timePicker.types.d.ts.map +1 -0
  31. package/dist/src/components/TimePicker/timePicker.variants.d.ts +103 -0
  32. package/dist/src/components/TimePicker/timePicker.variants.d.ts.map +1 -0
  33. package/dist/src/index.d.ts +15 -3
  34. package/dist/src/index.d.ts.map +1 -1
  35. package/package.json +4 -3
  36. package/src/components/Calendar/Calendar.test.tsx +75 -0
  37. package/src/components/Calendar/Calendar.tsx +264 -0
  38. package/src/components/Calendar/calendar.types.ts +65 -0
  39. package/src/components/Calendar/calendar.variants.ts +64 -0
  40. package/src/components/DatePicker/DatePicker.test.tsx +76 -0
  41. package/src/components/DatePicker/DatePicker.tsx +305 -0
  42. package/src/components/DatePicker/datePicker.types.ts +77 -0
  43. package/src/components/DatePicker/datePicker.variants.ts +50 -0
  44. package/src/components/DateRangePicker/DateRangePicker.test.tsx +92 -0
  45. package/src/components/DateRangePicker/DateRangePicker.tsx +510 -0
  46. package/src/components/DateRangePicker/dateRangePicker.types.ts +74 -0
  47. package/src/components/DateRangePicker/dateRangePicker.variants.ts +101 -0
  48. package/src/components/DateTimePicker/DateTimePicker.test.tsx +60 -186
  49. package/src/components/DateTimePicker/DateTimePicker.tsx +209 -181
  50. package/src/components/DateTimePicker/dateTimePicker.types.ts +50 -54
  51. package/src/components/DateTimePicker/dateTimePicker.variants.ts +22 -51
  52. package/src/components/TimePicker/TimePicker.test.tsx +65 -0
  53. package/src/components/TimePicker/TimePicker.tsx +274 -0
  54. package/src/components/TimePicker/timePicker.types.ts +69 -0
  55. package/src/components/TimePicker/timePicker.variants.ts +53 -0
  56. package/src/index.ts +38 -6
  57. package/tsconfig.lib.json +7 -4
  58. package/src/components/DateTimePicker/DateTimePicker.perf.test.tsx +0 -153
@@ -1,84 +1,55 @@
1
- import { tv, type VariantProps } from 'tailwind-variants';
1
+ import { tv } from 'tailwind-variants';
2
2
 
3
3
  /**
4
- * Tailwind-variants recipe for `<DateTimePicker>` with 6 slots.
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
- * Styling mirrors `<TextField>` for layout / sizes / error states so
7
- * the two field types compose visually in the same form.
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
- inputWrapper: [
26
- 'relative flex items-center',
27
- 'rounded-md border bg-neutral-50',
28
- 'border-neutral-300',
29
- 'transition-colors',
30
- 'focus-within:border-primary-500 focus-within:ring-2 focus-within:ring-primary-500/30',
31
- ],
32
- input: [
33
- 'block w-full bg-transparent outline-none',
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: { root: 'flex-col' },
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
- inputWrapper:
60
- 'border-danger-500 focus-within:border-danger-500 focus-within:ring-danger-500/30',
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
- inputWrapper: 'w-full',
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
- // F5-B tier-3: native HTML5 date / time / datetime picker, bridge-integrated.
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
- DateTimePicker,
102
- isoToInputValue,
103
- } from './components/DateTimePicker/DateTimePicker.js';
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.7.0-beta';
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": "../tw-tokens/tsconfig.lib.json"
43
+ "path": "../rbac/tsconfig.lib.json"
44
44
  },
45
45
  {
46
- "path": "../tw-theme/tsconfig.lib.json"
46
+ "path": "../forms/tsconfig.lib.json"
47
47
  },
48
48
  {
49
- "path": "../rbac/tsconfig.lib.json"
49
+ "path": "../calendar-core/tsconfig.lib.json"
50
50
  },
51
51
  {
52
- "path": "../forms/tsconfig.lib.json"
52
+ "path": "../tw-tokens/tsconfig.lib.json"
53
+ },
54
+ {
55
+ "path": "../tw-theme/tsconfig.lib.json"
53
56
  }
54
57
  ]
55
58
  }