@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,101 @@
1
+ import { tv } from 'tailwind-variants';
2
+
3
+ /**
4
+ * Slots for `<DateRangePicker>` — a bridge-integrated start/end date field
5
+ * whose trigger is a `<button>` styled as an input, opening a dual-month
6
+ * range calendar popover.
7
+ *
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`.
11
+ */
12
+ export const dateRangePickerVariants = tv({
13
+ slots: {
14
+ root: 'flex flex-col',
15
+ label: 'block text-sm font-medium text-neutral-900 mb-1',
16
+ requiredMark: 'text-danger-500 ml-0.5',
17
+ trigger:
18
+ 'flex items-center gap-2 h-10 min-w-[15rem] 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 gap-3 p-3 select-none',
23
+ month: 'flex flex-col gap-2',
24
+ header: 'flex items-center justify-between gap-1',
25
+ navButton:
26
+ 'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[1rem] text-neutral-600 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
27
+ navSpacer: 'h-7 w-7 shrink-0',
28
+ monthLabel: 'flex-1 text-center text-sm font-semibold text-neutral-900',
29
+ grid: 'flex flex-col gap-1',
30
+ weekdayRow: 'grid grid-cols-7 gap-1',
31
+ weekday:
32
+ 'flex h-8 items-center justify-center text-xs font-semibold text-neutral-500',
33
+ weekRow: 'grid grid-cols-7 gap-1',
34
+ helperText: 'mt-1 text-sm text-neutral-600',
35
+ errorText: 'mt-1 text-sm text-danger-600',
36
+ },
37
+ variants: {
38
+ layout: {
39
+ stacked: {},
40
+ inline: {
41
+ root: 'flex-row items-start gap-3',
42
+ label: 'mb-0 pt-2.5 whitespace-nowrap shrink-0',
43
+ },
44
+ },
45
+ error: {
46
+ true: {
47
+ trigger:
48
+ 'border-danger-500 hover:border-danger-500 focus-visible:border-danger-500 focus-visible:ring-danger-500/30',
49
+ },
50
+ },
51
+ fullWidth: {
52
+ true: {
53
+ root: 'w-full',
54
+ trigger: 'w-full min-w-0',
55
+ },
56
+ },
57
+ },
58
+ defaultVariants: {
59
+ layout: 'stacked',
60
+ error: false,
61
+ fullWidth: false,
62
+ },
63
+ });
64
+
65
+ /**
66
+ * Per-day-cell recipe for the range grid. `endpoint` (a committed start/end)
67
+ * is the solid fill; `inBand` (inside the committed or previewed range) is
68
+ * the light tint. Declared `inBand` before `endpoint` so an endpoint cell —
69
+ * which is also in-band — resolves to the solid fill.
70
+ */
71
+ export const dateRangeDayVariants = tv({
72
+ base: 'flex h-9 w-9 items-center justify-center rounded-md 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',
73
+ variants: {
74
+ inBand: {
75
+ true: 'bg-primary-100 hover:bg-primary-100',
76
+ },
77
+ endpoint: {
78
+ true: 'bg-primary-600 text-white hover:bg-primary-600',
79
+ },
80
+ siblingMonth: {
81
+ true: 'text-neutral-400',
82
+ },
83
+ today: {
84
+ true: 'font-bold ring-1 ring-inset ring-primary-400',
85
+ },
86
+ disabled: {
87
+ true: 'cursor-default text-neutral-300 opacity-60 hover:bg-transparent',
88
+ },
89
+ },
90
+ compoundVariants: [
91
+ { endpoint: true, today: true, class: 'ring-0' },
92
+ { endpoint: true, siblingMonth: true, class: 'text-white' },
93
+ ],
94
+ defaultVariants: {
95
+ inBand: false,
96
+ endpoint: false,
97
+ siblingMonth: false,
98
+ today: false,
99
+ disabled: false,
100
+ },
101
+ });
@@ -1,204 +1,78 @@
1
1
  // @vitest-environment jsdom
2
- import { describe, it, expect, vi } from 'vitest';
2
+ import { describe, it, expect, vi, beforeAll } from 'vitest';
3
3
  import { render, screen, fireEvent } from '@testing-library/react';
4
- import { DashFormProvider } from '@dashforge/forms';
5
- import { DateTimePicker, isoToInputValue } from './DateTimePicker.js';
6
4
 
7
- describe('isoToInputValue (pure helper)', () => {
8
- it('returns "" for null / undefined / empty', () => {
9
- expect(isoToInputValue('date', null)).toBe('');
10
- expect(isoToInputValue('date', undefined)).toBe('');
11
- expect(isoToInputValue('date', '')).toBe('');
12
- });
13
-
14
- it('passes through canonical `YYYY-MM-DD` for date mode', () => {
15
- expect(isoToInputValue('date', '2026-05-16')).toBe('2026-05-16');
16
- });
17
-
18
- it('passes through canonical `HH:mm` for time mode', () => {
19
- expect(isoToInputValue('time', '09:42')).toBe('09:42');
20
- });
21
-
22
- it('passes through canonical `HH:mm:ss` for time mode', () => {
23
- expect(isoToInputValue('time', '09:42:15')).toBe('09:42:15');
24
- });
5
+ import { DateTimePicker } from './DateTimePicker.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
25
 
26
- it('passes through canonical `YYYY-MM-DDTHH:mm` for datetime mode', () => {
27
- expect(isoToInputValue('datetime', '2026-05-16T09:42')).toBe(
28
- '2026-05-16T09:42'
26
+ describe('DateTimePicker', () => {
27
+ it('renders a labelled trigger with the placeholder', () => {
28
+ render(
29
+ <DateTimePicker name="appt" label="Appointment" placeholder="Pick one" />,
29
30
  );
31
+ expect(screen.queryByText('Appointment')).not.toBeNull();
32
+ expect(screen.getByRole('button').textContent).toContain('Pick one');
30
33
  });
31
34
 
32
- it('truncates a rich ISO datetime to the date mode prefix', () => {
33
- expect(isoToInputValue('date', '2026-05-16T09:42:15.000Z')).toBe(
34
- '2026-05-16'
35
+ it('shows a formatted controlled value', () => {
36
+ render(<DateTimePicker name="d" value="2026-05-20T14:30" />);
37
+ expect(screen.getByRole('button').textContent).toContain(
38
+ 'May 20, 2026, 14:30',
35
39
  );
36
40
  });
37
41
 
38
- it('returns "" for an unparseable string', () => {
39
- expect(isoToInputValue('date', 'not-a-date')).toBe('');
40
- });
41
- });
42
-
43
- describe('<DateTimePicker>', () => {
44
- describe('standalone (no DashForm)', () => {
45
- it('renders a native date input by default', () => {
46
- render(<DateTimePicker name="dob" label="DOB" />);
47
- const input = screen.getByLabelText('DOB') as HTMLInputElement;
48
- expect(input.type).toBe('date');
49
- });
50
-
51
- it('renders a time input when mode="time"', () => {
52
- render(<DateTimePicker name="t" label="Time" mode="time" />);
53
- const input = screen.getByLabelText('Time') as HTMLInputElement;
54
- expect(input.type).toBe('time');
55
- });
56
-
57
- it('renders a datetime-local input when mode="datetime"', () => {
58
- render(<DateTimePicker name="dt" label="When" mode="datetime" />);
59
- const input = screen.getByLabelText('When') as HTMLInputElement;
60
- expect(input.type).toBe('datetime-local');
61
- });
62
-
63
- it('seeds `defaultValue` (uncontrolled)', () => {
64
- render(
65
- <DateTimePicker name="dob" label="DOB" defaultValue="2026-05-16" />
66
- );
67
- const input = screen.getByLabelText('DOB') as HTMLInputElement;
68
- expect(input.defaultValue).toBe('2026-05-16');
69
- });
70
-
71
- it('reflects controlled `value`', () => {
72
- render(
73
- <DateTimePicker name="dob" label="DOB" value="2026-05-16" />
74
- );
75
- const input = screen.getByLabelText('DOB') as HTMLInputElement;
76
- expect(input.value).toBe('2026-05-16');
77
- });
78
-
79
- it('truncates a controlled rich ISO to the date mode prefix', () => {
80
- render(
81
- <DateTimePicker
82
- name="dob"
83
- label="DOB"
84
- value="2026-05-16T09:42:15.000Z"
85
- />
86
- );
87
- const input = screen.getByLabelText('DOB') as HTMLInputElement;
88
- expect(input.value).toBe('2026-05-16');
89
- });
90
-
91
- it('forwards min/max/step to the native input', () => {
92
- render(
93
- <DateTimePicker
94
- name="t"
95
- label="Time"
96
- mode="time"
97
- min="08:00"
98
- max="18:00"
99
- step={300}
100
- />
101
- );
102
- const input = screen.getByLabelText('Time') as HTMLInputElement;
103
- expect(input.min).toBe('08:00');
104
- expect(input.max).toBe('18:00');
105
- expect(input.step).toBe('300');
106
- });
107
-
108
- it('renders a `*` for required fields and sets the native attr', () => {
109
- render(<DateTimePicker name="dob" label="DOB" required />);
110
- const input = screen.getByLabelText(/DOB/) as HTMLInputElement;
111
- expect(input.required).toBe(true);
112
- expect(screen.getByText('*')).toBeTruthy();
113
- });
114
-
115
- it('renders helperText when not in error', () => {
116
- render(
117
- <DateTimePicker
118
- name="dob"
119
- label="DOB"
120
- helperText="Pick a date in the past."
121
- />
122
- );
123
- expect(screen.getByText('Pick a date in the past.')).toBeTruthy();
124
- });
125
-
126
- it('shows the helperText in the error slot when `error` is true', () => {
127
- render(
128
- <DateTimePicker
129
- name="dob"
130
- label="DOB"
131
- error
132
- helperText="Required"
133
- />
134
- );
135
- const p = screen.getByText('Required');
136
- // The error slot has the `text-danger-600` class per the variants.
137
- expect(p.className).toMatch(/danger/);
138
- });
139
-
140
- it('disables the input', () => {
141
- render(<DateTimePicker name="dob" label="DOB" disabled />);
142
- const input = screen.getByLabelText('DOB') as HTMLInputElement;
143
- expect(input.disabled).toBe(true);
144
- });
42
+ it('opens a popover with a calendar and a time list', () => {
43
+ render(<DateTimePicker name="d" defaultValue="2026-05-20T10:00" />);
44
+ expect(screen.queryByRole('dialog')).toBeNull();
45
+ fireEvent.click(screen.getByRole('button'));
46
+ expect(screen.queryByRole('dialog')).not.toBeNull();
47
+ expect(screen.getByRole('grid')).not.toBeNull();
48
+ expect(screen.getByRole('listbox', { name: 'Time options' })).not.toBeNull();
145
49
  });
146
50
 
147
- describe('event wiring', () => {
148
- it('fires onValueChange with the typed value (date)', () => {
149
- const onValueChange = vi.fn();
150
- render(
151
- <DateTimePicker
152
- name="dob"
153
- label="DOB"
154
- onValueChange={onValueChange}
155
- />
156
- );
157
- const input = screen.getByLabelText('DOB') as HTMLInputElement;
158
- fireEvent.change(input, { target: { value: '2026-12-25' } });
159
- expect(onValueChange).toHaveBeenCalledWith('2026-12-25');
160
- });
161
-
162
- it('fires onValueChange with `null` when the input is cleared', () => {
163
- const onValueChange = vi.fn();
164
- render(
165
- <DateTimePicker
166
- name="dob"
167
- label="DOB"
168
- value="2026-05-16"
169
- onValueChange={onValueChange}
170
- />
171
- );
172
- const input = screen.getByLabelText('DOB') as HTMLInputElement;
173
- fireEvent.change(input, { target: { value: '' } });
174
- expect(onValueChange).toHaveBeenCalledWith(null);
175
- });
51
+ it('picking a date updates the value and keeps the popover open', () => {
52
+ const onChange = vi.fn();
53
+ render(
54
+ <DateTimePicker name="d" defaultValue="2026-05-20T10:00" onChange={onChange} />,
55
+ );
56
+ fireEvent.click(screen.getByRole('button'));
57
+ fireEvent.click(screen.getByRole('button', { name: /May 15, 2026/ }));
58
+ expect(onChange).toHaveBeenCalledWith('2026-05-15T10:00');
59
+ expect(screen.queryByRole('dialog')).not.toBeNull();
176
60
  });
177
61
 
178
- describe('inside DashFormProvider', () => {
179
- it('mounts cleanly with the bridge attached', () => {
180
- render(
181
- <DashFormProvider defaultValues={{ dob: '2026-05-16' }}>
182
- <DateTimePicker name="dob" label="DOB" />
183
- </DashFormProvider>
184
- );
185
- // Mount assertion — defaultValues commit is timing-sensitive in
186
- // vitest (mirror of how Autocomplete / RadioGroup / NumberField
187
- // are tested), so we don't assert input.value here.
188
- expect(screen.getByLabelText('DOB')).toBeTruthy();
189
- });
62
+ it('picking a time completes the value and closes the popover', () => {
63
+ const onChange = vi.fn();
64
+ render(
65
+ <DateTimePicker name="d" defaultValue="2026-05-20T10:00" onChange={onChange} />,
66
+ );
67
+ fireEvent.click(screen.getByRole('button'));
68
+ fireEvent.click(screen.getByRole('option', { name: '14:30' }));
69
+ expect(onChange).toHaveBeenCalledWith('2026-05-20T14:30');
70
+ expect(screen.queryByRole('dialog')).toBeNull();
190
71
  });
191
72
 
192
- describe('visibility', () => {
193
- it('returns null when visibleWhen returns false', () => {
194
- const { container } = render(
195
- <DateTimePicker
196
- name="dob"
197
- label="DOB"
198
- visibleWhen={() => false}
199
- />
200
- );
201
- expect(container.querySelector('input')).toBeNull();
202
- });
73
+ it('does not open the popover when disabled', () => {
74
+ render(<DateTimePicker name="d" disabled />);
75
+ fireEvent.click(screen.getByRole('button'));
76
+ expect(screen.queryByRole('dialog')).toBeNull();
203
77
  });
204
78
  });