tharaday 0.8.2 → 0.8.3

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 (170) hide show
  1. package/dist/{src/components → components}/Accordion/Accordion.d.ts +0 -1
  2. package/dist/{src/components → components}/Avatar/Avatar.d.ts +0 -1
  3. package/dist/{src/components → components}/Badge/Badge.d.ts +0 -1
  4. package/dist/{src/components → components}/Box/Box.d.ts +0 -1
  5. package/dist/{src/components → components}/Box/Box.types.d.ts +11 -11
  6. package/dist/{src/components → components}/Box/helpers/getSpacingStyles.d.ts +2 -2
  7. package/dist/{src/components → components}/Breadcrumbs/Breadcrumbs.d.ts +0 -1
  8. package/dist/{src/components → components}/Button/Button.d.ts +0 -1
  9. package/dist/{src/components → components}/Card/Card.d.ts +0 -1
  10. package/dist/{src/components → components}/Checkbox/Checkbox.d.ts +0 -1
  11. package/dist/components/DatePicker/DatePicker.d.ts +1 -0
  12. package/dist/{src/components → components}/Divider/Divider.d.ts +0 -1
  13. package/dist/components/Drawer/Drawer.d.ts +1 -0
  14. package/dist/{src/components → components}/Dropdown/Dropdown.d.ts +0 -1
  15. package/dist/components/EmptyState/EmptyState.d.ts +1 -0
  16. package/dist/{src/components → components}/Header/Header.d.ts +0 -1
  17. package/dist/{src/components → components}/Input/Input.d.ts +0 -1
  18. package/dist/{src/components → components}/List/List.d.ts +2 -2
  19. package/dist/{src/components → components}/List/List.types.d.ts +3 -3
  20. package/dist/{src/components → components}/List/ListItem.d.ts +1 -1
  21. package/dist/{src/components → components}/List/ListItem.types.d.ts +1 -1
  22. package/dist/{src/components → components}/Loader/Loader.d.ts +0 -1
  23. package/dist/{src/components → components}/Modal/Modal.d.ts +1 -2
  24. package/dist/{src/components → components}/NavBar/NavBar.d.ts +0 -1
  25. package/dist/{src/components → components}/Notification/Notification.d.ts +0 -1
  26. package/dist/{src/components → components}/Pagination/Pagination.d.ts +0 -1
  27. package/dist/components/Popover/Popover.d.ts +1 -0
  28. package/dist/{src/components → components}/ProgressBar/ProgressBar.d.ts +0 -1
  29. package/dist/{src/components → components}/RadioButton/RadioButton.d.ts +0 -1
  30. package/dist/{src/components → components}/Select/Select.d.ts +0 -1
  31. package/dist/{src/components → components}/Skeleton/Skeleton.d.ts +0 -1
  32. package/dist/{src/components → components}/Slider/Slider.d.ts +0 -1
  33. package/dist/{src/components → components}/Stepper/Step.d.ts +0 -1
  34. package/dist/{src/components → components}/Stepper/Stepper.d.ts +0 -1
  35. package/dist/{src/components → components}/Stepper/stepper.utils.d.ts +2 -2
  36. package/dist/{src/components → components}/Switch/Switch.d.ts +0 -1
  37. package/dist/{src/components → components}/Table/Table.d.ts +0 -1
  38. package/dist/{src/components → components}/Tabs/Tabs.d.ts +0 -1
  39. package/dist/components/Tag/Tag.d.ts +1 -0
  40. package/dist/{src/components → components}/Text/Text.d.ts +0 -1
  41. package/dist/{src/components → components}/Textarea/Textarea.d.ts +0 -1
  42. package/dist/{src/components → components}/Tooltip/Tooltip.d.ts +0 -1
  43. package/dist/{src/components → components}/Tree/Tree.d.ts +2 -2
  44. package/dist/{src/components → components}/Tree/Tree.types.d.ts +1 -1
  45. package/dist/{src/components → components}/Tree/TreeItem.d.ts +1 -1
  46. package/dist/{src/components → components}/Tree/TreeItem.types.d.ts +1 -1
  47. package/dist/ds.css +1 -1
  48. package/dist/ds.js +1294 -1149
  49. package/dist/ds.umd.cjs +1 -1
  50. package/dist/hooks/useClickOutside.d.ts +6 -0
  51. package/dist/{src/hooks → hooks}/useComponentId.d.ts +1 -1
  52. package/dist/hooks/useFocusTrap.d.ts +17 -0
  53. package/dist/{src/index.d.ts → index.d.ts} +10 -0
  54. package/dist/{src/layouts → layouts}/AppLayout/AppLayout.d.ts +0 -1
  55. package/dist/{src/layouts → layouts}/AuthLayout/AuthLayout.d.ts +0 -1
  56. package/dist/{src/layouts → layouts}/DashboardLayout/DashboardLayout.d.ts +0 -1
  57. package/dist/{src/layouts → layouts}/SettingsLayout/SettingsLayout.d.ts +0 -1
  58. package/package.json +11 -10
  59. package/src/components/Box/Box.module.css +0 -557
  60. package/src/components/Box/Box.test.tsx +4 -4
  61. package/src/components/Box/Box.tsx +8 -16
  62. package/src/components/Box/helpers/getSpacingStyles.ts +23 -17
  63. package/src/components/DatePicker/DatePicker.module.css +212 -0
  64. package/src/components/DatePicker/DatePicker.stories.tsx +53 -0
  65. package/src/components/DatePicker/DatePicker.test.tsx +61 -0
  66. package/src/components/DatePicker/DatePicker.tsx +269 -0
  67. package/src/components/DatePicker/DatePicker.types.ts +11 -0
  68. package/src/components/Drawer/Drawer.module.css +126 -0
  69. package/src/components/Drawer/Drawer.stories.tsx +70 -0
  70. package/src/components/Drawer/Drawer.test.tsx +49 -0
  71. package/src/components/Drawer/Drawer.tsx +77 -0
  72. package/src/components/Drawer/Drawer.types.ts +17 -0
  73. package/src/components/EmptyState/EmptyState.module.css +73 -0
  74. package/src/components/EmptyState/EmptyState.stories.tsx +65 -0
  75. package/src/components/EmptyState/EmptyState.test.tsx +30 -0
  76. package/src/components/EmptyState/EmptyState.tsx +29 -0
  77. package/src/components/EmptyState/EmptyState.types.ts +12 -0
  78. package/src/components/Header/Header.test.tsx +5 -5
  79. package/src/components/Modal/Modal.tsx +2 -62
  80. package/src/components/Popover/Popover.module.css +52 -0
  81. package/src/components/Popover/Popover.stories.tsx +67 -0
  82. package/src/components/Popover/Popover.test.tsx +40 -0
  83. package/src/components/Popover/Popover.tsx +78 -0
  84. package/src/components/Popover/Popover.types.ts +13 -0
  85. package/src/components/Tag/Tag.module.css +115 -0
  86. package/src/components/Tag/Tag.stories.tsx +61 -0
  87. package/src/components/Tag/Tag.test.tsx +42 -0
  88. package/src/components/Tag/Tag.tsx +74 -0
  89. package/src/components/Tag/Tag.types.ts +15 -0
  90. package/src/components/Text/Text.module.css +0 -521
  91. package/src/components/Text/Text.test.tsx +4 -4
  92. package/src/components/Text/Text.tsx +0 -14
  93. package/src/components/Tooltip/Tooltip.module.css +1 -1
  94. package/src/components/Tooltip/Tooltip.test.tsx +5 -5
  95. package/src/components/Tooltip/Tooltip.tsx +2 -6
  96. package/src/hooks/useClickOutside.test.tsx +68 -0
  97. package/src/hooks/useClickOutside.ts +35 -0
  98. package/src/hooks/useFocusTrap.test.tsx +95 -0
  99. package/src/hooks/useFocusTrap.ts +88 -0
  100. package/src/index.ts +10 -0
  101. package/src/styles/themes.browser.test.ts +75 -0
  102. package/vite.config.ts +1 -1
  103. package/dist/src/components/Accordion/Accordion.stories.d.ts +0 -14
  104. package/dist/src/components/Accordion/Accordion.types.d.ts +0 -18
  105. package/dist/src/components/Avatar/Avatar.stories.d.ts +0 -14
  106. package/dist/src/components/Avatar/Avatar.types.d.ts +0 -10
  107. package/dist/src/components/Badge/Badge.stories.d.ts +0 -33
  108. package/dist/src/components/Badge/Badge.types.d.ts +0 -10
  109. package/dist/src/components/Box/Box.stories.d.ts +0 -38
  110. package/dist/src/components/Breadcrumbs/Breadcrumbs.stories.d.ts +0 -13
  111. package/dist/src/components/Breadcrumbs/Breadcrumbs.types.d.ts +0 -11
  112. package/dist/src/components/Button/Button.stories.d.ts +0 -22
  113. package/dist/src/components/Button/Button.types.d.ts +0 -12
  114. package/dist/src/components/Card/Card.stories.d.ts +0 -27
  115. package/dist/src/components/Card/Card.types.d.ts +0 -16
  116. package/dist/src/components/Checkbox/Checkbox.stories.d.ts +0 -17
  117. package/dist/src/components/Checkbox/Checkbox.types.d.ts +0 -7
  118. package/dist/src/components/Divider/Divider.stories.d.ts +0 -15
  119. package/dist/src/components/Divider/Divider.types.d.ts +0 -10
  120. package/dist/src/components/Dropdown/Dropdown.stories.d.ts +0 -12
  121. package/dist/src/components/Dropdown/Dropdown.types.d.ts +0 -24
  122. package/dist/src/components/Header/Header.stories.d.ts +0 -20
  123. package/dist/src/components/Header/Header.types.d.ts +0 -16
  124. package/dist/src/components/Input/Input.stories.d.ts +0 -32
  125. package/dist/src/components/Input/Input.types.d.ts +0 -10
  126. package/dist/src/components/List/List.stories.d.ts +0 -25
  127. package/dist/src/components/Loader/Loader.stories.d.ts +0 -25
  128. package/dist/src/components/Loader/Loader.types.d.ts +0 -8
  129. package/dist/src/components/Modal/Modal.stories.d.ts +0 -28
  130. package/dist/src/components/Modal/Modal.types.d.ts +0 -12
  131. package/dist/src/components/NavBar/NavBar.stories.d.ts +0 -8
  132. package/dist/src/components/NavBar/NavBar.types.d.ts +0 -38
  133. package/dist/src/components/Notification/Notification.stories.d.ts +0 -26
  134. package/dist/src/components/Notification/Notification.types.d.ts +0 -9
  135. package/dist/src/components/Pagination/Pagination.stories.d.ts +0 -21
  136. package/dist/src/components/Pagination/Pagination.types.d.ts +0 -34
  137. package/dist/src/components/ProgressBar/ProgressBar.stories.d.ts +0 -32
  138. package/dist/src/components/ProgressBar/ProgressBar.types.d.ts +0 -12
  139. package/dist/src/components/RadioButton/RadioButton.stories.d.ts +0 -30
  140. package/dist/src/components/RadioButton/RadioButton.types.d.ts +0 -9
  141. package/dist/src/components/Select/Select.stories.d.ts +0 -32
  142. package/dist/src/components/Select/Select.types.d.ts +0 -23
  143. package/dist/src/components/Skeleton/Skeleton.stories.d.ts +0 -15
  144. package/dist/src/components/Skeleton/Skeleton.types.d.ts +0 -9
  145. package/dist/src/components/Slider/Slider.stories.d.ts +0 -23
  146. package/dist/src/components/Slider/Slider.types.d.ts +0 -15
  147. package/dist/src/components/Stepper/Step.types.d.ts +0 -18
  148. package/dist/src/components/Stepper/Stepper.stories.d.ts +0 -15
  149. package/dist/src/components/Stepper/Stepper.types.d.ts +0 -14
  150. package/dist/src/components/Switch/Switch.stories.d.ts +0 -16
  151. package/dist/src/components/Switch/Switch.types.d.ts +0 -6
  152. package/dist/src/components/Table/Table.stories.d.ts +0 -27
  153. package/dist/src/components/Table/Table.types.d.ts +0 -19
  154. package/dist/src/components/Tabs/Tabs.stories.d.ts +0 -19
  155. package/dist/src/components/Tabs/Tabs.types.d.ts +0 -16
  156. package/dist/src/components/Text/Text.stories.d.ts +0 -78
  157. package/dist/src/components/Text/Text.types.d.ts +0 -52
  158. package/dist/src/components/Textarea/Textarea.stories.d.ts +0 -32
  159. package/dist/src/components/Textarea/Textarea.types.d.ts +0 -11
  160. package/dist/src/components/Tooltip/Tooltip.stories.d.ts +0 -10
  161. package/dist/src/components/Tooltip/Tooltip.types.d.ts +0 -12
  162. package/dist/src/components/Tree/Tree.stories.d.ts +0 -27
  163. package/dist/src/layouts/AppLayout/AppLayout.stories.d.ts +0 -13
  164. package/dist/src/layouts/AppLayout/AppLayout.types.d.ts +0 -13
  165. package/dist/src/layouts/AuthLayout/AuthLayout.stories.d.ts +0 -12
  166. package/dist/src/layouts/AuthLayout/AuthLayout.types.d.ts +0 -8
  167. package/dist/src/layouts/DashboardLayout/DashboardLayout.stories.d.ts +0 -11
  168. package/dist/src/layouts/DashboardLayout/DashboardLayout.types.d.ts +0 -10
  169. package/dist/src/layouts/SettingsLayout/SettingsLayout.stories.d.ts +0 -11
  170. package/dist/src/layouts/SettingsLayout/SettingsLayout.types.d.ts +0 -9
@@ -0,0 +1,212 @@
1
+ .root {
2
+ position: relative;
3
+ display: inline-flex;
4
+ flex-direction: column;
5
+ gap: var(--ds-space-2);
6
+ font-family: var(--ds-font-family-base);
7
+ }
8
+
9
+ .label {
10
+ font-size: var(--ds-font-size-sm);
11
+ font-weight: var(--ds-font-weight-medium);
12
+ color: var(--ds-text-1);
13
+ }
14
+
15
+ .trigger {
16
+ all: unset;
17
+ display: inline-flex;
18
+ align-items: center;
19
+ justify-content: space-between;
20
+ gap: var(--ds-space-2);
21
+ height: var(--ds-space-10);
22
+ padding: 0 var(--ds-space-3);
23
+ border: 1px solid var(--ds-border-1);
24
+ border-radius: var(--ds-radius-md);
25
+ background-color: var(--ds-surface-0);
26
+ color: var(--ds-text-1);
27
+ font-size: var(--ds-font-size-sm);
28
+ font-family: var(--ds-font-family-base);
29
+ cursor: pointer;
30
+ min-width: 12rem;
31
+ box-sizing: border-box;
32
+ transition:
33
+ border-color 0.15s,
34
+ box-shadow 0.15s;
35
+ }
36
+ .trigger:hover:not(:disabled) {
37
+ border-color: var(--ds-neutral);
38
+ }
39
+ .trigger:focus-visible {
40
+ outline: none;
41
+ border-color: var(--ds-ring);
42
+ box-shadow:
43
+ 0 0 0 2px var(--ds-ring-offset),
44
+ 0 0 0 4px var(--ds-ring);
45
+ }
46
+ .trigger.disabled,
47
+ .trigger:disabled {
48
+ opacity: 0.5;
49
+ cursor: not-allowed;
50
+ }
51
+
52
+ .triggerText {
53
+ flex: 1;
54
+ text-align: left;
55
+ }
56
+ .placeholder {
57
+ color: var(--ds-text-disabled);
58
+ }
59
+
60
+ .calendarIcon {
61
+ color: var(--ds-text-2);
62
+ flex-shrink: 0;
63
+ }
64
+
65
+ /* Calendar panel */
66
+ .calendar {
67
+ position: absolute;
68
+ top: calc(100% + var(--ds-space-2));
69
+ left: 0;
70
+ z-index: 500;
71
+ background-color: var(--ds-surface-0);
72
+ border: 1px solid var(--ds-border-1);
73
+ border-radius: var(--ds-radius-md);
74
+ box-shadow: var(--ds-shadow-md);
75
+ padding: var(--ds-space-3);
76
+ min-width: 16rem;
77
+ }
78
+
79
+ .calendarHeader {
80
+ display: flex;
81
+ align-items: center;
82
+ justify-content: space-between;
83
+ margin-bottom: var(--ds-space-3);
84
+ }
85
+
86
+ .monthYear {
87
+ font-size: var(--ds-font-size-sm);
88
+ font-weight: var(--ds-font-weight-medium);
89
+ color: var(--ds-text-1);
90
+ }
91
+
92
+ .navButton {
93
+ all: unset;
94
+ display: inline-flex;
95
+ align-items: center;
96
+ justify-content: center;
97
+ width: 1.75rem;
98
+ height: 1.75rem;
99
+ border-radius: var(--ds-radius-sm);
100
+ cursor: pointer;
101
+ color: var(--ds-text-2);
102
+ transition:
103
+ background-color 0.15s,
104
+ color 0.15s;
105
+ }
106
+ .navButton:hover {
107
+ background-color: var(--ds-surface-1);
108
+ color: var(--ds-text-1);
109
+ }
110
+ .navButton:focus-visible {
111
+ outline: none;
112
+ box-shadow:
113
+ 0 0 0 2px var(--ds-ring-offset),
114
+ 0 0 0 4px var(--ds-ring);
115
+ }
116
+
117
+ .weekdays {
118
+ display: grid;
119
+ grid-template-columns: repeat(7, 1fr);
120
+ margin-bottom: var(--ds-space-1);
121
+ }
122
+
123
+ .weekday {
124
+ text-align: center;
125
+ font-size: var(--ds-font-size-xs);
126
+ font-weight: var(--ds-font-weight-medium);
127
+ color: var(--ds-text-2);
128
+ padding: var(--ds-space-1) 0;
129
+ }
130
+
131
+ .days {
132
+ display: grid;
133
+ grid-template-columns: repeat(7, 1fr);
134
+ gap: 2px;
135
+ }
136
+
137
+ .dayEmpty {
138
+ aspect-ratio: 1;
139
+ }
140
+
141
+ .day {
142
+ all: unset;
143
+ display: flex;
144
+ align-items: center;
145
+ justify-content: center;
146
+ aspect-ratio: 1;
147
+ border-radius: var(--ds-radius-sm);
148
+ font-size: var(--ds-font-size-xs);
149
+ cursor: pointer;
150
+ color: var(--ds-text-1);
151
+ transition:
152
+ background-color 0.1s,
153
+ color 0.1s;
154
+ }
155
+ .day:hover:not(:disabled) {
156
+ background-color: var(--ds-surface-1);
157
+ }
158
+ .day:focus-visible {
159
+ outline: none;
160
+ box-shadow:
161
+ 0 0 0 2px var(--ds-ring-offset),
162
+ 0 0 0 4px var(--ds-ring);
163
+ }
164
+
165
+ .daySelected {
166
+ background-color: var(--ds-info);
167
+ color: var(--ds-text-on-brand);
168
+ font-weight: var(--ds-font-weight-medium);
169
+ }
170
+ .daySelected:hover {
171
+ background-color: var(--ds-info-hover);
172
+ }
173
+
174
+ .dayToday {
175
+ font-weight: var(--ds-font-weight-bold);
176
+ color: var(--ds-info);
177
+ }
178
+
179
+ .dayDisabled {
180
+ opacity: 0.35;
181
+ cursor: not-allowed;
182
+ }
183
+
184
+ .calendarFooter {
185
+ margin-top: var(--ds-space-3);
186
+ padding-top: var(--ds-space-2);
187
+ border-top: 1px solid var(--ds-border-2);
188
+ display: flex;
189
+ justify-content: flex-end;
190
+ }
191
+
192
+ .clearButton {
193
+ all: unset;
194
+ font-size: var(--ds-font-size-xs);
195
+ color: var(--ds-text-2);
196
+ cursor: pointer;
197
+ padding: var(--ds-space-1) var(--ds-space-2);
198
+ border-radius: var(--ds-radius-sm);
199
+ transition:
200
+ background-color 0.15s,
201
+ color 0.15s;
202
+ }
203
+ .clearButton:hover {
204
+ background-color: var(--ds-surface-1);
205
+ color: var(--ds-text-1);
206
+ }
207
+ .clearButton:focus-visible {
208
+ outline: none;
209
+ box-shadow:
210
+ 0 0 0 2px var(--ds-ring-offset),
211
+ 0 0 0 4px var(--ds-ring);
212
+ }
@@ -0,0 +1,53 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+
4
+ import { DatePicker } from './DatePicker.tsx';
5
+
6
+ const meta = {
7
+ title: 'Components/DatePicker',
8
+ component: DatePicker,
9
+ tags: ['autodocs'],
10
+ parameters: { layout: 'centered' },
11
+ } satisfies Meta<typeof DatePicker>;
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof meta>;
15
+
16
+ const Controlled = (args: React.ComponentProps<typeof DatePicker>) => {
17
+ const [value, setValue] = useState<Date | null>(null);
18
+ return <DatePicker {...args} value={value} onChange={setValue} />;
19
+ };
20
+
21
+ const Preselected = (args: React.ComponentProps<typeof DatePicker>) => {
22
+ const [value, setValue] = useState<Date | null>(new Date(2026, 0, 15));
23
+ return <DatePicker {...args} value={value} onChange={setValue} />;
24
+ };
25
+
26
+ const WithMinMaxDemo = (args: React.ComponentProps<typeof DatePicker>) => {
27
+ const [value, setValue] = useState<Date | null>(null);
28
+ return <DatePicker {...args} value={value} onChange={setValue} />;
29
+ };
30
+
31
+ export const Default: Story = {
32
+ render: (args) => <Controlled {...args} />,
33
+ args: { label: 'Date', placeholder: 'Select date' },
34
+ };
35
+
36
+ export const WithPreselectedDate: Story = {
37
+ render: (args) => <Preselected {...args} />,
38
+ args: { label: 'Date' },
39
+ };
40
+
41
+ export const Disabled: Story = {
42
+ args: { label: 'Date', disabled: true },
43
+ };
44
+
45
+ export const WithMinMax: Story = {
46
+ render: (args) => <WithMinMaxDemo {...args} />,
47
+ args: {
48
+ label: 'Date',
49
+ min: new Date(2026, 3, 1),
50
+ max: new Date(2026, 3, 30),
51
+ placeholder: 'April 2026 only',
52
+ },
53
+ };
@@ -0,0 +1,61 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+
4
+ import { DatePicker } from './DatePicker.tsx';
5
+
6
+ describe('DatePicker', () => {
7
+ it('renders placeholder when no value', () => {
8
+ render(<DatePicker placeholder="Pick a date" />);
9
+ expect(screen.getByText('Pick a date')).toBeInTheDocument();
10
+ });
11
+
12
+ it('renders formatted value when provided', () => {
13
+ render(<DatePicker value={new Date(2026, 0, 15)} />);
14
+ expect(screen.getByText('Jan 15, 2026')).toBeInTheDocument();
15
+ });
16
+
17
+ it('renders label when provided', () => {
18
+ render(<DatePicker label="Due date" />);
19
+ expect(screen.getByText('Due date')).toBeInTheDocument();
20
+ });
21
+
22
+ it('opens calendar on trigger click', async () => {
23
+ render(<DatePicker />);
24
+ await userEvent.click(screen.getByRole('button'));
25
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
26
+ });
27
+
28
+ it('closes calendar on Escape', async () => {
29
+ render(<DatePicker />);
30
+ await userEvent.click(screen.getByRole('button'));
31
+ await userEvent.keyboard('{Escape}');
32
+ expect(screen.queryByRole('dialog')).toBeNull();
33
+ });
34
+
35
+ it('calls onChange when a day is selected', async () => {
36
+ const onChange = vi.fn();
37
+ render(<DatePicker onChange={onChange} />);
38
+ await userEvent.click(screen.getByRole('button'));
39
+ const dayButtons = screen
40
+ .getAllByRole('button')
41
+ .filter((b) => /^\d+$/.test(b.textContent ?? ''));
42
+ await userEvent.click(dayButtons[0]);
43
+ expect(onChange).toHaveBeenCalledOnce();
44
+ expect(onChange.mock.calls[0][0]).toBeInstanceOf(Date);
45
+ });
46
+
47
+ it('does not open when disabled', async () => {
48
+ render(<DatePicker disabled />);
49
+ await userEvent.click(screen.getByRole('button'));
50
+ expect(screen.queryByRole('dialog')).toBeNull();
51
+ });
52
+
53
+ it('navigates to previous month', async () => {
54
+ render(<DatePicker value={new Date(2026, 3, 1)} />);
55
+ await userEvent.click(screen.getByRole('button', { name: 'Apr 1, 2026' }));
56
+ const monthYear = screen.getByText('April 2026');
57
+ expect(monthYear).toBeInTheDocument();
58
+ await userEvent.click(screen.getByRole('button', { name: 'Previous month' }));
59
+ expect(screen.getByText('March 2026')).toBeInTheDocument();
60
+ });
61
+ });
@@ -0,0 +1,269 @@
1
+ import classnames from 'classnames';
2
+ import { useRef, useState } from 'react';
3
+
4
+ import { useClickOutside } from '../../hooks/useClickOutside.ts';
5
+ import { useComponentId } from '../../hooks/useComponentId.ts';
6
+ import styles from './DatePicker.module.css';
7
+ import type { DatePickerProps } from './DatePicker.types.ts';
8
+
9
+ const DAYS_OF_WEEK = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
10
+ const MONTHS = [
11
+ 'January',
12
+ 'February',
13
+ 'March',
14
+ 'April',
15
+ 'May',
16
+ 'June',
17
+ 'July',
18
+ 'August',
19
+ 'September',
20
+ 'October',
21
+ 'November',
22
+ 'December',
23
+ ];
24
+
25
+ const formatDate = (date: Date): string =>
26
+ date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
27
+
28
+ const isSameDay = (a: Date, b: Date): boolean =>
29
+ a.getFullYear() === b.getFullYear() &&
30
+ a.getMonth() === b.getMonth() &&
31
+ a.getDate() === b.getDate();
32
+
33
+ const isToday = (date: Date): boolean => isSameDay(date, new Date());
34
+
35
+ export const DatePicker = ({
36
+ value,
37
+ onChange,
38
+ placeholder = 'Select date',
39
+ disabled = false,
40
+ min,
41
+ max,
42
+ id,
43
+ label,
44
+ className,
45
+ }: DatePickerProps) => {
46
+ const today = new Date();
47
+ const [isOpen, setIsOpen] = useState(false);
48
+ const [viewYear, setViewYear] = useState((value ?? today).getFullYear());
49
+ const [viewMonth, setViewMonth] = useState((value ?? today).getMonth());
50
+ const rootRef = useRef<HTMLDivElement>(null);
51
+ const componentId = useComponentId('datepicker', id);
52
+ const inputId = `${componentId}-input`;
53
+ const calendarId = `${componentId}-calendar`;
54
+
55
+ useClickOutside(rootRef, () => setIsOpen(false), isOpen);
56
+
57
+ const open = () => {
58
+ if (disabled) {
59
+ return;
60
+ }
61
+ if (value) {
62
+ setViewYear(value.getFullYear());
63
+ setViewMonth(value.getMonth());
64
+ }
65
+ setIsOpen(true);
66
+ };
67
+
68
+ const selectDate = (date: Date) => {
69
+ onChange?.(date);
70
+ setIsOpen(false);
71
+ };
72
+
73
+ const prevMonth = () => {
74
+ if (viewMonth === 0) {
75
+ setViewMonth(11);
76
+ setViewYear((y) => y - 1);
77
+ } else {
78
+ setViewMonth((m) => m - 1);
79
+ }
80
+ };
81
+
82
+ const nextMonth = () => {
83
+ if (viewMonth === 11) {
84
+ setViewMonth(0);
85
+ setViewYear((y) => y + 1);
86
+ } else {
87
+ setViewMonth((m) => m + 1);
88
+ }
89
+ };
90
+
91
+ const isDisabledDate = (date: Date): boolean => {
92
+ const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate());
93
+ if (min) {
94
+ const minOnly = new Date(min.getFullYear(), min.getMonth(), min.getDate());
95
+ if (dateOnly < minOnly) {
96
+ return true;
97
+ }
98
+ }
99
+ if (max) {
100
+ const maxOnly = new Date(max.getFullYear(), max.getMonth(), max.getDate());
101
+ if (dateOnly > maxOnly) {
102
+ return true;
103
+ }
104
+ }
105
+ return false;
106
+ };
107
+
108
+ const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
109
+ const firstDayOfMonth = new Date(viewYear, viewMonth, 1).getDay();
110
+
111
+ const cells: Array<Date | null> = [
112
+ ...Array<null>(firstDayOfMonth).fill(null),
113
+ ...Array.from({ length: daysInMonth }, (_, i) => new Date(viewYear, viewMonth, i + 1)),
114
+ ];
115
+ while (cells.length % 7 !== 0) {
116
+ cells.push(null);
117
+ }
118
+
119
+ return (
120
+ <div ref={rootRef} className={classnames(styles.root, className)}>
121
+ {label && (
122
+ <label htmlFor={inputId} className={styles.label}>
123
+ {label}
124
+ </label>
125
+ )}
126
+ <button
127
+ type="button"
128
+ id={inputId}
129
+ className={classnames(styles.trigger, disabled && styles.disabled)}
130
+ onClick={open}
131
+ aria-haspopup="dialog"
132
+ aria-expanded={isOpen}
133
+ aria-controls={calendarId}
134
+ disabled={disabled}
135
+ >
136
+ <span className={classnames(styles.triggerText, !value && styles.placeholder)}>
137
+ {value ? formatDate(value) : placeholder}
138
+ </span>
139
+ <svg
140
+ width="14"
141
+ height="14"
142
+ viewBox="0 0 14 14"
143
+ fill="none"
144
+ aria-hidden="true"
145
+ className={styles.calendarIcon}
146
+ >
147
+ <rect
148
+ x="1"
149
+ y="2"
150
+ width="12"
151
+ height="11"
152
+ rx="1.5"
153
+ stroke="currentColor"
154
+ strokeWidth="1.5"
155
+ />
156
+ <path d="M1 5.5H13" stroke="currentColor" strokeWidth="1.5" />
157
+ <path d="M4.5 1V3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
158
+ <path d="M9.5 1V3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
159
+ </svg>
160
+ </button>
161
+
162
+ {isOpen && (
163
+ <div
164
+ id={calendarId}
165
+ role="dialog"
166
+ aria-label="Date picker calendar"
167
+ aria-modal="false"
168
+ className={styles.calendar}
169
+ >
170
+ <div className={styles.calendarHeader}>
171
+ <button
172
+ type="button"
173
+ className={styles.navButton}
174
+ onClick={prevMonth}
175
+ aria-label="Previous month"
176
+ >
177
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
178
+ <path
179
+ d="M6.5 2L3.5 5L6.5 8"
180
+ stroke="currentColor"
181
+ strokeWidth="1.5"
182
+ strokeLinecap="round"
183
+ strokeLinejoin="round"
184
+ />
185
+ </svg>
186
+ </button>
187
+ <span className={styles.monthYear}>
188
+ {MONTHS[viewMonth]} {viewYear}
189
+ </span>
190
+ <button
191
+ type="button"
192
+ className={styles.navButton}
193
+ onClick={nextMonth}
194
+ aria-label="Next month"
195
+ >
196
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
197
+ <path
198
+ d="M3.5 2L6.5 5L3.5 8"
199
+ stroke="currentColor"
200
+ strokeWidth="1.5"
201
+ strokeLinecap="round"
202
+ strokeLinejoin="round"
203
+ />
204
+ </svg>
205
+ </button>
206
+ </div>
207
+
208
+ <div className={styles.weekdays} aria-hidden="true">
209
+ {DAYS_OF_WEEK.map((d) => (
210
+ <span key={d} className={styles.weekday}>
211
+ {d}
212
+ </span>
213
+ ))}
214
+ </div>
215
+
216
+ <div className={styles.days}>
217
+ {cells.map((date, i) => {
218
+ if (!date) {
219
+ return <span key={`empty-${i}`} className={styles.dayEmpty} aria-hidden="true" />;
220
+ }
221
+ const selected = value ? isSameDay(date, value) : false;
222
+ const todayFlag = isToday(date);
223
+ const disabledFlag = isDisabledDate(date);
224
+ return (
225
+ <button
226
+ key={date.toISOString()}
227
+ type="button"
228
+ className={classnames(
229
+ styles.day,
230
+ selected && styles.daySelected,
231
+ todayFlag && !selected && styles.dayToday,
232
+ disabledFlag && styles.dayDisabled
233
+ )}
234
+ onClick={() => !disabledFlag && selectDate(date)}
235
+ disabled={disabledFlag}
236
+ aria-selected={selected}
237
+ aria-label={date.toLocaleDateString('en-US', {
238
+ weekday: 'long',
239
+ year: 'numeric',
240
+ month: 'long',
241
+ day: 'numeric',
242
+ })}
243
+ tabIndex={disabledFlag ? -1 : 0}
244
+ >
245
+ {date.getDate()}
246
+ </button>
247
+ );
248
+ })}
249
+ </div>
250
+
251
+ {value && (
252
+ <div className={styles.calendarFooter}>
253
+ <button
254
+ type="button"
255
+ className={styles.clearButton}
256
+ onClick={() => {
257
+ onChange?.(null);
258
+ setIsOpen(false);
259
+ }}
260
+ >
261
+ Clear
262
+ </button>
263
+ </div>
264
+ )}
265
+ </div>
266
+ )}
267
+ </div>
268
+ );
269
+ };
@@ -0,0 +1,11 @@
1
+ export interface DatePickerProps {
2
+ value?: Date | null;
3
+ onChange?: (date: Date | null) => void;
4
+ placeholder?: string;
5
+ disabled?: boolean;
6
+ min?: Date;
7
+ max?: Date;
8
+ id?: string;
9
+ label?: string;
10
+ className?: string;
11
+ }