@varialkit/datepicker 0.1.1

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/docs.md ADDED
@@ -0,0 +1,61 @@
1
+ # DatePicker
2
+
3
+ The DatePicker exposes a calendar panel (`DatePickerPanel`) plus a trigger-driven wrapper (`DatePicker`) that opens the
4
+ panel inside a `Menu` surface. This keeps the calendar usable inline or in a dropdown without duplicating logic.
5
+
6
+ ## Exports
7
+
8
+ - `DatePicker`
9
+ - `DatePickerPanel`
10
+
11
+ ## Notes
12
+
13
+ - `DatePicker` uses `MenuDropdown` and disables the menu's automatic close-on-select behavior so month navigation
14
+ doesn't close the panel.
15
+ - Use `DatePickerPanel` when you need an inline calendar (e.g. inside a card or form section).
16
+ - The default trigger label can be customized via `placeholder` or `formatLabel`.
17
+ - `DatePicker` and `DatePickerPanel` forward `react-day-picker` props like `mode`, `selected`, and `onSelect`.
18
+
19
+ ## Header + View Modes (New)
20
+
21
+ The calendar header is now custom and always visible at the top of the month view:
22
+
23
+ - Left nav button: previous month
24
+ - Center buttons: month and year (toggle mode)
25
+ - Right nav button: next month
26
+
27
+ Clicking the month or year button does **not** open a detached popover. Instead, it swaps the calendar **body** area:
28
+
29
+ - Default body: day grid
30
+ - Month mode: month grid (Jan-Dec)
31
+ - Year mode: paged year grid (12 years per page)
32
+
33
+ Selecting a month/year updates the visible calendar month and returns to the default day grid.
34
+
35
+ ## Structure: Where The Logic Lives
36
+
37
+ - Header controls and mode toggles: `DatePickerMonthCaption` in `src/DatePicker.tsx`
38
+ - Body swapping (day grid vs month/year grids): custom `MonthGrid` component in `DatePickerPanel` (`src/DatePicker.tsx`)
39
+ - Month/year selection content: `DatePickerHeaderPanelView` in `src/DatePicker.tsx`
40
+ - Shared mode state (`activePanel`, `yearPageStart`): `DatePickerHeaderViewContext` in `src/DatePicker.tsx`
41
+ - Layout and active-state styles: `src/DatePicker.scss`
42
+
43
+ ## Props and Behavior Notes
44
+
45
+ - `DatePickerPanel` defaults:
46
+ - `captionLayout="label"`
47
+ - `navLayout="around"`
48
+ - `hideNavigation=true` (built-in DayPicker nav is hidden because custom header nav is used)
49
+ - `mode`, `selected`, and `onSelect` are mode-aware and support single/multiple/range usage.
50
+ - In dropdown usage (`DatePicker` wrapper), `closeOnSelect` logic remains selection-aware:
51
+ - single: closes after a valid selection
52
+ - range: closes after a complete non-single-day range
53
+ - multiple: remains open by default
54
+
55
+ ## Maintenance Tips
56
+
57
+ - If header row alignment regresses, check `solara-datepicker__caption-controls` and
58
+ `solara-datepicker__header-nav-button` in `src/DatePicker.scss`.
59
+ - If month/year view swapping regresses, check the `MonthGrid` override and `activePanel` flow in `src/DatePicker.tsx`.
60
+ - If React warns about unknown DOM props, ensure custom DayPicker component props (e.g. `displayIndex`,
61
+ `calendarMonth`) are stripped before spreading to native elements.
package/examples.tsx ADDED
@@ -0,0 +1,93 @@
1
+ import React, { useState } from "react";
2
+ import type { DateRange } from "react-day-picker";
3
+ import { DatePicker, DatePickerPanel } from "./src";
4
+
5
+ export const stories = {
6
+ triggered: {
7
+ title: "Triggered picker",
8
+ description: "A menu-triggered date picker with a default button trigger.",
9
+ showProps: false,
10
+ render: () => <TriggeredExample />,
11
+ code: `import React from "react";
12
+ import { DatePicker } from "@solara/datepicker";
13
+
14
+ export function Example() {
15
+ const [selected, setSelected] = React.useState<Date | undefined>(new Date());
16
+
17
+ return (
18
+ <DatePicker
19
+ selected={selected}
20
+ onSelect={setSelected}
21
+ placeholder="Pick a date"
22
+ />
23
+ );
24
+ }
25
+ `,
26
+ },
27
+ range: {
28
+ title: "Range selection",
29
+ description: "Use range mode with a two-date selection.",
30
+ showProps: false,
31
+ render: () => <RangeExample />,
32
+ code: `import React from "react";
33
+ import type { DateRange } from "react-day-picker";
34
+ import { DatePicker } from "@solara/datepicker";
35
+
36
+ export function Example() {
37
+ const [range, setRange] = React.useState<DateRange | undefined>();
38
+
39
+ return (
40
+ <DatePicker
41
+ mode="range"
42
+ selected={range}
43
+ onSelect={setRange}
44
+ placeholder="Select range"
45
+ />
46
+ );
47
+ }
48
+ `,
49
+ },
50
+ inline: {
51
+ title: "Inline panel",
52
+ description: "DatePickerPanel can render inline without a menu trigger.",
53
+ showProps: false,
54
+ render: () => <InlineExample />,
55
+ code: `import React from "react";
56
+ import { DatePickerPanel } from "@solara/datepicker";
57
+
58
+ export function Example() {
59
+ const [selected, setSelected] = React.useState<Date | undefined>(new Date());
60
+
61
+ return (
62
+ <DatePickerPanel
63
+ selected={selected}
64
+ onSelect={setSelected}
65
+ variant="standalone"
66
+ />
67
+ );
68
+ }
69
+ `,
70
+ },
71
+ };
72
+
73
+ function TriggeredExample() {
74
+ const [selected, setSelected] = useState<Date | undefined>(new Date());
75
+ return <DatePicker selected={selected} onSelect={setSelected} placeholder="Pick a date" />;
76
+ }
77
+
78
+ function RangeExample() {
79
+ const [range, setRange] = useState<DateRange | undefined>();
80
+ return (
81
+ <DatePicker
82
+ mode="range"
83
+ selected={range}
84
+ onSelect={setRange}
85
+ placeholder="Select range"
86
+ />
87
+ );
88
+ }
89
+
90
+ function InlineExample() {
91
+ const [selected, setSelected] = useState<Date | undefined>(new Date());
92
+ return <DatePickerPanel selected={selected} onSelect={setSelected} variant="standalone" />;
93
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@varialkit/datepicker",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./examples": "./examples.tsx"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "docs.md",
14
+ "examples.tsx"
15
+ ],
16
+ "peerDependencies": {
17
+ "react": "^19.0.0"
18
+ },
19
+ "dependencies": {
20
+ "react-day-picker": "^9.6.3",
21
+ "@varialkit/menu": "0.1.1"
22
+ },
23
+ "devDependencies": {
24
+ "@types/react": "19.0.10",
25
+ "react": "19.0.0"
26
+ }
27
+ }
@@ -0,0 +1,414 @@
1
+ .solara-datepicker {
2
+ --datepicker-cell-size: 2.25rem;
3
+ --datepicker-body-min-height: calc(var(--datepicker-cell-size) * 7);
4
+ --datepicker-surface: transparent;
5
+ --datepicker-border: var(--color-divider-primary);
6
+ --datepicker-text: var(--color-text-primary);
7
+ --datepicker-text-muted: var(--color-text-secondary);
8
+ --datepicker-accent: var(--color-accent-primary);
9
+ --datepicker-accent-muted: color-mix(in srgb, var(--color-accent-primary) 15%, transparent);
10
+ --datepicker-radius: var(--radius-2);
11
+ --datepicker-padding: calc(var(--space-2) * var(--spacing-multiplier));
12
+
13
+ padding: var(--datepicker-padding);
14
+ background: var(--datepicker-surface);
15
+ border-radius: var(--datepicker-radius);
16
+ border: 1px solid transparent;
17
+ color: var(--datepicker-text);
18
+ font-family: var(--font-body);
19
+ }
20
+
21
+ .solara-datepicker[data-variant="standalone"] {
22
+ --datepicker-padding: calc(var(--space-3) * var(--spacing-multiplier));
23
+ --datepicker-surface: var(--color-surface-100);
24
+ border-color: var(--color-surface-400);
25
+ box-shadow: var(--elevation-2);
26
+ }
27
+
28
+ .solara-datepicker__months {
29
+ display: flex;
30
+ flex-direction: column;
31
+ gap: calc(var(--space-3) * var(--spacing-multiplier));
32
+ }
33
+
34
+ .solara-datepicker__month {
35
+ display: grid;
36
+ grid-template-columns: 1fr;
37
+ grid-template-rows: var(--datepicker-cell-size) auto;
38
+ row-gap: calc(var(--space-3) * var(--spacing-multiplier));
39
+ }
40
+
41
+ .solara-datepicker__nav {
42
+ display: none;
43
+ }
44
+
45
+ .solara-datepicker__nav-button {
46
+ display: inline-flex;
47
+ align-items: center;
48
+ justify-content: center;
49
+ width: var(--datepicker-cell-size);
50
+ height: var(--datepicker-cell-size);
51
+ border-radius: var(--radius-2);
52
+ border: 1px solid var(--color-surface-400);
53
+ background: var(--color-surface-100);
54
+ color: var(--color-text-secondary);
55
+ transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
56
+ pointer-events: auto;
57
+
58
+ &:hover {
59
+ background: var(--color-surface-200);
60
+ }
61
+
62
+ &:active {
63
+ transform: translateY(1px);
64
+ }
65
+
66
+ &:disabled {
67
+ opacity: 0.5;
68
+ cursor: not-allowed;
69
+ }
70
+ }
71
+
72
+ .solara-datepicker__caption {
73
+ grid-row: 1;
74
+ grid-column: 1;
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ height: var(--datepicker-cell-size);
79
+ padding: 0 calc(var(--datepicker-cell-size) + 0.375rem);
80
+ z-index: 1;
81
+ }
82
+
83
+ .solara-datepicker__caption-controls {
84
+ display: grid;
85
+ grid-template-columns: var(--datepicker-cell-size) minmax(0, 1fr) var(--datepicker-cell-size);
86
+ align-items: center;
87
+ width: 100%;
88
+ gap: calc(var(--space-1) * var(--spacing-multiplier));
89
+ }
90
+
91
+ .solara-datepicker__caption-title-buttons {
92
+ display: inline-flex;
93
+ align-items: center;
94
+ justify-self: center;
95
+ gap: calc(var(--space-1) * var(--spacing-multiplier));
96
+ }
97
+
98
+ .solara-datepicker__header-nav-button {
99
+ display: inline-flex;
100
+ align-items: center;
101
+ justify-content: center;
102
+ width: var(--datepicker-cell-size);
103
+ height: var(--datepicker-cell-size);
104
+ border-radius: var(--radius-2);
105
+ border: 1px solid var(--color-surface-400);
106
+ background: var(--color-surface-100);
107
+ color: var(--color-text-secondary);
108
+ cursor: pointer;
109
+
110
+ &:hover {
111
+ background: var(--color-surface-200);
112
+ }
113
+
114
+ &:disabled {
115
+ opacity: 0.5;
116
+ cursor: not-allowed;
117
+ }
118
+ }
119
+
120
+ .solara-datepicker__caption-controls > .solara-datepicker__header-nav-button:first-child {
121
+ justify-self: start;
122
+ }
123
+
124
+ .solara-datepicker__caption-controls > .solara-datepicker__header-nav-button:last-child {
125
+ justify-self: end;
126
+ }
127
+
128
+ .solara-datepicker__caption-button {
129
+ display: inline-flex;
130
+ align-items: center;
131
+ justify-content: center;
132
+ width: auto;
133
+ min-height: calc(var(--datepicker-cell-size) - 0.5rem);
134
+ padding: 0 calc(var(--space-2) * var(--spacing-multiplier));
135
+ border: none;
136
+ border-radius: var(--radius-2);
137
+ background: transparent;
138
+ color: var(--color-text-primary);
139
+ font-size: var(--font-size-caption-scaled);
140
+ font-weight: 500;
141
+ line-height: 1;
142
+ white-space: nowrap;
143
+ cursor: pointer;
144
+ transition: background-color 0.2s ease, border-color 0.2s ease;
145
+
146
+ &:hover {
147
+ background: var(--color-surface-200);
148
+ }
149
+ }
150
+
151
+ .solara-datepicker__caption-button--active {
152
+ background: var(--datepicker-accent-muted);
153
+ }
154
+
155
+ .solara-datepicker__caption-panel {
156
+ position: relative;
157
+ grid-row: 2;
158
+ grid-column: 1;
159
+ min-width: 100%;
160
+ min-height: var(--datepicker-body-min-height);
161
+ padding: calc(var(--space-2) * var(--spacing-multiplier));
162
+ border: 1px solid var(--color-surface-400);
163
+ border-radius: var(--radius-2);
164
+ background: var(--color-surface-100);
165
+ box-shadow: var(--elevation-1);
166
+ display: flex;
167
+ flex-direction: column;
168
+ }
169
+
170
+ .solara-datepicker__month-grid,
171
+ .solara-datepicker__year-grid {
172
+ display: grid;
173
+ grid-template-columns: repeat(3, minmax(0, 1fr));
174
+ gap: calc(var(--space-1) * var(--spacing-multiplier));
175
+ }
176
+
177
+ .solara-datepicker__year-nav {
178
+ display: flex;
179
+ align-items: center;
180
+ justify-content: space-between;
181
+ margin-bottom: calc(var(--space-2) * var(--spacing-multiplier));
182
+ }
183
+
184
+ .solara-datepicker__year-nav-label {
185
+ font-size: var(--font-size-footnote-scaled);
186
+ color: var(--color-text-secondary);
187
+ }
188
+
189
+ .solara-datepicker__panel-nav-button,
190
+ .solara-datepicker__panel-option {
191
+ display: inline-flex;
192
+ align-items: center;
193
+ justify-content: center;
194
+ width: 100%;
195
+ min-height: 2rem;
196
+ border: 1px solid var(--color-surface-400);
197
+ border-radius: var(--radius-2);
198
+ background: var(--color-surface-100);
199
+ color: var(--color-text-primary);
200
+ cursor: pointer;
201
+ }
202
+
203
+ .solara-datepicker__panel-nav-button {
204
+ width: 2rem;
205
+ min-height: 2rem;
206
+ }
207
+
208
+ .solara-datepicker__panel-option {
209
+ font-size: var(--font-size-footnote-scaled);
210
+
211
+ &:hover {
212
+ background: var(--color-surface-200);
213
+ }
214
+ }
215
+
216
+ .solara-datepicker__panel-option--active {
217
+ border-color: color-mix(in srgb, var(--datepicker-accent) 55%, var(--color-surface-400));
218
+ background: var(--datepicker-accent-muted);
219
+ }
220
+
221
+ .solara-datepicker__dropdowns {
222
+ display: flex;
223
+ align-items: center;
224
+ justify-content: center;
225
+ gap: calc(var(--space-2) * var(--spacing-multiplier));
226
+ font-size: var(--font-size-caption-scaled);
227
+ font-weight: 500;
228
+ }
229
+
230
+ .solara-datepicker__dropdown-root {
231
+ position: relative;
232
+ border: 1px solid var(--color-surface-400);
233
+ border-radius: var(--radius-2);
234
+ background: var(--color-surface-100);
235
+ box-shadow: var(--elevation-1);
236
+ }
237
+
238
+ .solara-datepicker__dropdown {
239
+ position: absolute;
240
+ inset: 0;
241
+ opacity: 0;
242
+ }
243
+
244
+ .solara-datepicker__caption-label {
245
+ font-size: var(--font-size-caption-scaled);
246
+ font-weight: 500;
247
+ color: var(--color-text-primary);
248
+ }
249
+
250
+ .solara-datepicker__table {
251
+ grid-row: 2;
252
+ grid-column: 1;
253
+ width: 100%;
254
+ min-height: var(--datepicker-body-min-height);
255
+ border-collapse: collapse;
256
+ }
257
+
258
+ .solara-datepicker__weekdays {
259
+ display: flex;
260
+ }
261
+
262
+ .solara-datepicker__weekday {
263
+ flex: 1;
264
+ text-align: center;
265
+ font-size: var(--font-size-footnote-scaled);
266
+ color: var(--color-text-tertiary);
267
+ border-radius: var(--radius-2);
268
+ }
269
+
270
+ .solara-datepicker__week {
271
+ display: flex;
272
+ width: 100%;
273
+ margin-top: calc(var(--space-2) * var(--spacing-multiplier));
274
+ }
275
+
276
+ .solara-datepicker__day {
277
+ position: relative;
278
+ flex: 1;
279
+ }
280
+
281
+ .solara-datepicker__day-button {
282
+ display: flex;
283
+ flex-direction: column;
284
+ align-items: center;
285
+ justify-content: center;
286
+ width: 100%;
287
+ aspect-ratio: 1 / 1;
288
+ min-width: var(--datepicker-cell-size);
289
+ border-radius: var(--radius-2);
290
+ border: 1px solid transparent;
291
+ color: var(--color-text-secondary);
292
+ background: transparent;
293
+ transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
294
+
295
+ &:hover {
296
+ background: var(--color-surface-200);
297
+ }
298
+
299
+ &:focus-visible {
300
+ outline: none;
301
+ box-shadow: 0 0 0 3px var(--color-focus-halo);
302
+ }
303
+ }
304
+
305
+ .solara-datepicker__today {
306
+ border: 1px solid var(--color-divider-accent);
307
+ border-radius: var(--radius-2);
308
+ }
309
+
310
+ .solara-datepicker__selected {
311
+ background: var(--datepicker-accent);
312
+ color: var(--color-text-inverse);
313
+ border-radius: var(--radius-2);
314
+ border: 1px solid transparent;
315
+
316
+ &:hover {
317
+ background: color-mix(in srgb, var(--datepicker-accent) 90%, var(--color-surface-0));
318
+ color: var(--color-text-inverse);
319
+ }
320
+ }
321
+
322
+ .solara-datepicker__today.solara-datepicker__selected {
323
+ border-color: transparent;
324
+ }
325
+
326
+ .solara-datepicker__range-start {
327
+ background: var(--datepicker-accent);
328
+ color: var(--color-text-inverse);
329
+ border-top-left-radius: var(--radius-2);
330
+ border-bottom-left-radius: var(--radius-2);
331
+ }
332
+
333
+ .solara-datepicker__range-middle {
334
+ background: var(--datepicker-accent-muted);
335
+ color: var(--color-text-primary);
336
+ border-radius: 0;
337
+ }
338
+
339
+ .solara-datepicker__range-end {
340
+ background: var(--datepicker-accent);
341
+ color: var(--color-text-inverse);
342
+ border-top-right-radius: var(--radius-2);
343
+ border-bottom-right-radius: var(--radius-2);
344
+ }
345
+
346
+ .solara-datepicker__outside {
347
+ color: var(--color-text-tertiary);
348
+ }
349
+
350
+ .solara-datepicker__disabled {
351
+ color: var(--color-text-tertiary);
352
+ opacity: 0.5;
353
+ }
354
+
355
+ .solara-datepicker__hidden {
356
+ visibility: hidden;
357
+ }
358
+
359
+ .solara-datepicker__week-number-cell {
360
+ display: flex;
361
+ width: var(--datepicker-cell-size);
362
+ height: var(--datepicker-cell-size);
363
+ align-items: center;
364
+ justify-content: center;
365
+ text-align: center;
366
+ color: var(--color-text-tertiary);
367
+ font-size: var(--font-size-footnote-scaled);
368
+ }
369
+
370
+ .solara-datepicker__chevron {
371
+ display: inline-flex;
372
+ font-size: 0.9em;
373
+ line-height: 1;
374
+ }
375
+
376
+ .solara-datepicker__menu {
377
+ --menu-padding: 0px;
378
+ min-width: 260px;
379
+ }
380
+
381
+ .solara-datepicker__trigger {
382
+ display: inline-flex;
383
+ align-items: center;
384
+ justify-content: space-between;
385
+ gap: calc(var(--space-2) * var(--spacing-multiplier));
386
+ padding: calc(var(--space-2) * var(--spacing-multiplier))
387
+ calc(var(--space-3) * var(--spacing-multiplier));
388
+ border-radius: var(--radius-2);
389
+ border: 1px solid var(--color-surface-400);
390
+ background: var(--color-surface-100);
391
+ color: var(--color-text-primary);
392
+ font-family: var(--font-body);
393
+ font-size: var(--font-size-body-scaled);
394
+ line-height: var(--line-height-body-scaled);
395
+ cursor: pointer;
396
+ min-width: 220px;
397
+ transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
398
+
399
+ &:hover {
400
+ background: var(--color-surface-200);
401
+ border-color: var(--color-divider-accent);
402
+ }
403
+
404
+ &:focus-visible {
405
+ outline: none;
406
+ box-shadow: 0 0 0 3px var(--color-focus-halo);
407
+ }
408
+ }
409
+
410
+ .solara-datepicker__trigger-label {
411
+ display: inline-flex;
412
+ align-items: center;
413
+ gap: calc(var(--space-2) * var(--spacing-multiplier));
414
+ }
@@ -0,0 +1,559 @@
1
+ import React from "react";
2
+ import { DayButton, DayPicker, getDefaultClassNames, useDayPicker } from "react-day-picker";
3
+ import type { DateRange, Modifiers } from "react-day-picker";
4
+ import type { CustomComponents } from "react-day-picker";
5
+ import type { MonthCaptionProps } from "react-day-picker";
6
+ import { Menu, MenuDropdown } from "@solara/menu";
7
+ import type { MenuDropdownProps } from "@solara/menu";
8
+ import type { DatePickerPanelProps, DatePickerProps, DatePickerSelection } from "./DatePicker.types";
9
+ import "./DatePicker.scss";
10
+
11
+ const DEFAULT_PLACEHOLDER = "Select date";
12
+ type DatePickerHeaderPanel = "month" | "year" | null;
13
+ const YEAR_PAGE_SIZE = 12;
14
+ const YEAR_PAGE_CENTER_OFFSET = 6;
15
+
16
+ type DatePickerHeaderViewState = {
17
+ // Which alternate body view is active under the header.
18
+ activePanel: DatePickerHeaderPanel;
19
+ setActivePanel: React.Dispatch<React.SetStateAction<DatePickerHeaderPanel>>;
20
+ // First visible year in the paged year grid.
21
+ yearPageStart: number;
22
+ setYearPageStart: React.Dispatch<React.SetStateAction<number>>;
23
+ };
24
+
25
+ const DatePickerHeaderViewContext = React.createContext<DatePickerHeaderViewState | null>(null);
26
+
27
+ function getCenteredYearPageStart(year: number) {
28
+ return year - YEAR_PAGE_CENTER_OFFSET;
29
+ }
30
+
31
+ function getYearPageOptions(yearPageStart: number) {
32
+ return Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i);
33
+ }
34
+
35
+ const DEFAULT_CLASSNAMES = {
36
+ root: "solara-datepicker",
37
+ months: "solara-datepicker__months",
38
+ month: "solara-datepicker__month",
39
+ nav: "solara-datepicker__nav",
40
+ button_previous: "solara-datepicker__nav-button",
41
+ button_next: "solara-datepicker__nav-button",
42
+ month_caption: "solara-datepicker__caption",
43
+ dropdowns: "solara-datepicker__dropdowns",
44
+ dropdown_root: "solara-datepicker__dropdown-root",
45
+ dropdown: "solara-datepicker__dropdown",
46
+ caption_label: "solara-datepicker__caption-label",
47
+ table: "solara-datepicker__table",
48
+ weekdays: "solara-datepicker__weekdays",
49
+ weekday: "solara-datepicker__weekday",
50
+ week: "solara-datepicker__week",
51
+ week_number_header: "solara-datepicker__week-number-header",
52
+ week_number: "solara-datepicker__week-number",
53
+ day: "solara-datepicker__day",
54
+ today: "solara-datepicker__today",
55
+ outside: "solara-datepicker__outside",
56
+ disabled: "solara-datepicker__disabled",
57
+ hidden: "solara-datepicker__hidden",
58
+ selected: "solara-datepicker__selected",
59
+ range_start: "solara-datepicker__range-start",
60
+ range_middle: "solara-datepicker__range-middle",
61
+ range_end: "solara-datepicker__range-end",
62
+ };
63
+
64
+ function cx(...values: Array<string | false | null | undefined>) {
65
+ return values.filter(Boolean).join(" ");
66
+ }
67
+
68
+ function formatSelectedLabel(value: DatePickerSelection) {
69
+ if (!value) return null;
70
+ if (value instanceof Date) return value.toLocaleDateString();
71
+ if (Array.isArray(value)) {
72
+ if (value.length === 0) return null;
73
+ if (value.length === 1) return value[0]?.toLocaleDateString();
74
+ const first = value[0];
75
+ const last = value[value.length - 1];
76
+ if (!first || !last) return null;
77
+ return `${first.toLocaleDateString()} – ${last.toLocaleDateString()}`;
78
+ }
79
+
80
+ const range = value as DateRange;
81
+ if (range?.from && range?.to) {
82
+ return `${range.from.toLocaleDateString()} – ${range.to.toLocaleDateString()}`;
83
+ }
84
+ if (range?.from) {
85
+ return range.from.toLocaleDateString();
86
+ }
87
+ return null;
88
+ }
89
+
90
+ function useControllableOpenState({
91
+ isOpen,
92
+ defaultOpen,
93
+ onOpenChange,
94
+ }: Pick<DatePickerProps, "isOpen" | "defaultOpen" | "onOpenChange">) {
95
+ // Support controlled and uncontrolled open state with one hook.
96
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(Boolean(defaultOpen));
97
+ const open = isOpen ?? uncontrolledOpen;
98
+
99
+ const setOpen = React.useCallback(
100
+ (next: boolean) => {
101
+ if (isOpen === undefined) setUncontrolledOpen(next);
102
+ onOpenChange?.(next);
103
+ },
104
+ [isOpen, onOpenChange]
105
+ );
106
+
107
+ return [open, setOpen] as const;
108
+ }
109
+
110
+ export function DatePickerPanel({
111
+ className,
112
+ variant = "standalone",
113
+ mode = "single",
114
+ showOutsideDays = true,
115
+ captionLayout = "label",
116
+ navLayout = "around",
117
+ hideNavigation = true,
118
+ formatters,
119
+ components,
120
+ classNames,
121
+ ...props
122
+ }: DatePickerPanelProps) {
123
+ // Header-level state drives the "swap the calendar body" behavior.
124
+ const [activePanel, setActivePanel] = React.useState<DatePickerHeaderPanel>(null);
125
+ const [yearPageStart, setYearPageStart] = React.useState(
126
+ getCenteredYearPageStart(new Date().getFullYear())
127
+ );
128
+ const defaultClassNames = React.useMemo(() => getDefaultClassNames(), []);
129
+ const mergedFormatters = React.useMemo(
130
+ () => ({
131
+ formatMonthDropdown: (date: Date) => date.toLocaleString("default", { month: "short" }),
132
+ ...formatters,
133
+ }),
134
+ [formatters]
135
+ );
136
+ const mergedClassNames = React.useMemo(
137
+ () => ({
138
+ root: cx(DEFAULT_CLASSNAMES.root, defaultClassNames.root),
139
+ months: cx(DEFAULT_CLASSNAMES.months, defaultClassNames.months),
140
+ month: cx(DEFAULT_CLASSNAMES.month, defaultClassNames.month),
141
+ nav: cx(DEFAULT_CLASSNAMES.nav, defaultClassNames.nav),
142
+ button_previous: cx(DEFAULT_CLASSNAMES.button_previous, defaultClassNames.button_previous),
143
+ button_next: cx(DEFAULT_CLASSNAMES.button_next, defaultClassNames.button_next),
144
+ month_caption: cx(DEFAULT_CLASSNAMES.month_caption, defaultClassNames.month_caption),
145
+ dropdowns: cx(DEFAULT_CLASSNAMES.dropdowns, defaultClassNames.dropdowns),
146
+ dropdown_root: cx(DEFAULT_CLASSNAMES.dropdown_root, defaultClassNames.dropdown_root),
147
+ dropdown: cx(DEFAULT_CLASSNAMES.dropdown, defaultClassNames.dropdown),
148
+ caption_label: cx(DEFAULT_CLASSNAMES.caption_label, defaultClassNames.caption_label),
149
+ table: cx(DEFAULT_CLASSNAMES.table),
150
+ weekdays: cx(DEFAULT_CLASSNAMES.weekdays, defaultClassNames.weekdays),
151
+ weekday: cx(DEFAULT_CLASSNAMES.weekday, defaultClassNames.weekday),
152
+ week: cx(DEFAULT_CLASSNAMES.week, defaultClassNames.week),
153
+ week_number_header: cx(
154
+ DEFAULT_CLASSNAMES.week_number_header,
155
+ defaultClassNames.week_number_header
156
+ ),
157
+ week_number: cx(DEFAULT_CLASSNAMES.week_number, defaultClassNames.week_number),
158
+ day: cx(DEFAULT_CLASSNAMES.day, defaultClassNames.day),
159
+ today: cx(DEFAULT_CLASSNAMES.today, defaultClassNames.today),
160
+ outside: cx(DEFAULT_CLASSNAMES.outside, defaultClassNames.outside),
161
+ disabled: cx(DEFAULT_CLASSNAMES.disabled, defaultClassNames.disabled),
162
+ hidden: cx(DEFAULT_CLASSNAMES.hidden, defaultClassNames.hidden),
163
+ selected: cx(DEFAULT_CLASSNAMES.selected, defaultClassNames.selected),
164
+ range_start: cx(DEFAULT_CLASSNAMES.range_start, defaultClassNames.range_start),
165
+ range_middle: cx(DEFAULT_CLASSNAMES.range_middle, defaultClassNames.range_middle),
166
+ range_end: cx(DEFAULT_CLASSNAMES.range_end, defaultClassNames.range_end),
167
+ ...classNames,
168
+ }),
169
+ [classNames, defaultClassNames]
170
+ );
171
+ const mergedComponents = React.useMemo<Partial<CustomComponents>>(() => {
172
+ // Root wrapper keeps Solara variant data attributes on the DayPicker root.
173
+ const Root: CustomComponents["Root"] = ({ className: rootClassName, rootRef, ...rootProps }) => (
174
+ <div
175
+ data-slot="datepicker"
176
+ data-variant={variant}
177
+ ref={rootRef}
178
+ className={cx(rootClassName)}
179
+ {...rootProps}
180
+ />
181
+ );
182
+ const Chevron: CustomComponents["Chevron"] = ({
183
+ className: chevronClassName,
184
+ orientation,
185
+ ...chevronProps
186
+ }) => (
187
+ <span
188
+ aria-hidden
189
+ className={cx("solara-datepicker__chevron", chevronClassName)}
190
+ data-orientation={orientation}
191
+ {...chevronProps}>
192
+ {orientation === "left" ? "‹" : orientation === "right" ? "›" : "▾"}
193
+ </span>
194
+ );
195
+ const WeekNumber: CustomComponents["WeekNumber"] = ({ children, ...weekProps }) => (
196
+ <td {...weekProps}>
197
+ <div className="solara-datepicker__week-number-cell">{children}</div>
198
+ </td>
199
+ );
200
+ // Keep the top caption/header mounted; only swap the body area between
201
+ // day-grid vs month/year grids.
202
+ const MonthGrid: CustomComponents["MonthGrid"] = ({ children, className }) =>
203
+ activePanel ? (
204
+ <div
205
+ className={cx("solara-datepicker__caption-panel", className)}
206
+ role="region">
207
+ <DatePickerHeaderPanelView />
208
+ </div>
209
+ ) : (
210
+ <table className={className}>{children}</table>
211
+ );
212
+
213
+ return {
214
+ Root,
215
+ Chevron,
216
+ // Custom caption owns the header controls and active panel toggles.
217
+ MonthCaption: DatePickerMonthCaption,
218
+ MonthGrid,
219
+ DayButton: DatePickerDayButton,
220
+ WeekNumber,
221
+ ...components,
222
+ };
223
+ }, [activePanel, components, variant]);
224
+
225
+ const dayPickerProps = {
226
+ mode,
227
+ showOutsideDays,
228
+ captionLayout,
229
+ navLayout,
230
+ hideNavigation,
231
+ formatters: mergedFormatters,
232
+ className,
233
+ classNames: mergedClassNames,
234
+ components: mergedComponents,
235
+ ...props,
236
+ } as React.ComponentProps<typeof DayPicker>;
237
+
238
+ return (
239
+ // Expose header mode state to both caption (top controls) and body swapper.
240
+ <DatePickerHeaderViewContext.Provider
241
+ value={{
242
+ activePanel,
243
+ setActivePanel,
244
+ yearPageStart,
245
+ setYearPageStart,
246
+ }}>
247
+ <DayPicker {...dayPickerProps} />
248
+ </DatePickerHeaderViewContext.Provider>
249
+ );
250
+ }
251
+
252
+ export function DatePicker({
253
+ trigger,
254
+ placeholder = DEFAULT_PLACEHOLDER,
255
+ formatLabel,
256
+ align = "start",
257
+ side = "bottom",
258
+ isOpen,
259
+ defaultOpen,
260
+ onOpenChange,
261
+ closeOnSelect = true,
262
+ menuProps,
263
+ contentAriaLabel = "Date picker",
264
+ triggerClassName,
265
+ ...panelProps
266
+ }: DatePickerProps) {
267
+ const [open, setOpen] = useControllableOpenState({
268
+ isOpen,
269
+ defaultOpen,
270
+ onOpenChange,
271
+ });
272
+
273
+ const { selected, onSelect } = panelProps as DatePickerPanelProps as {
274
+ selected?: DatePickerSelection;
275
+ onSelect?: (
276
+ selected: DatePickerSelection,
277
+ triggerDate: Date,
278
+ modifiers: Modifiers,
279
+ e: React.MouseEvent | React.KeyboardEvent
280
+ ) => void;
281
+ };
282
+ const selectionMode = (panelProps as DatePickerPanelProps).mode ?? "single";
283
+ const selectedLabel = React.useMemo(
284
+ () => (formatLabel ?? formatSelectedLabel)(selected),
285
+ [formatLabel, selected]
286
+ );
287
+ const triggerLabel = selectedLabel ?? placeholder;
288
+
289
+ const mergedTrigger: MenuDropdownProps["trigger"] = React.useMemo(
290
+ () =>
291
+ trigger ??
292
+ (({ getTriggerProps }) => (
293
+ <button
294
+ {...getTriggerProps({
295
+ type: "button",
296
+ className: cx("solara-datepicker__trigger", triggerClassName),
297
+ })}>
298
+ <span className="solara-datepicker__trigger-label">{triggerLabel}</span>
299
+ <span aria-hidden className="solara-menu-trigger-chevron">
300
+
301
+ </span>
302
+ </button>
303
+ )),
304
+ [trigger, triggerClassName, triggerLabel]
305
+ );
306
+
307
+ const handleSelect = React.useCallback(
308
+ (
309
+ selected: DatePickerSelection,
310
+ triggerDate: Date,
311
+ modifiers: Modifiers,
312
+ e: React.MouseEvent | React.KeyboardEvent
313
+ ) => {
314
+ (onSelect as ((...args: unknown[]) => void) | undefined)?.(
315
+ selected,
316
+ triggerDate,
317
+ modifiers,
318
+ e
319
+ );
320
+ // Caller can opt out of automatic close behavior.
321
+ if (!closeOnSelect) return;
322
+ const selection = selected;
323
+ const isRangeSelection =
324
+ selectionMode === "range" ||
325
+ (selection && typeof selection === "object" && "from" in selection);
326
+ if (isRangeSelection) {
327
+ const range = selection as DateRange | undefined;
328
+ const hasCompleteRange = Boolean(range?.from && range?.to);
329
+ const isSingleDayRange = Boolean(
330
+ range?.from && range?.to && range.from.getTime() === range.to.getTime()
331
+ );
332
+ // Close only when the range is complete and spans at least 2 days.
333
+ if (hasCompleteRange && !isSingleDayRange) setOpen(false);
334
+ return;
335
+ }
336
+ // Multiple mode stays open for additional picks.
337
+ if (selectionMode === "multiple") return;
338
+ // Single mode closes after selection.
339
+ setOpen(false);
340
+ },
341
+ [onSelect, closeOnSelect, selectionMode, setOpen]
342
+ );
343
+
344
+ return (
345
+ <MenuDropdown
346
+ trigger={mergedTrigger}
347
+ align={align}
348
+ side={side}
349
+ isOpen={open}
350
+ onOpenChange={setOpen}
351
+ closeOnSelect={false}
352
+ contentAriaLabel={contentAriaLabel}>
353
+ <Menu
354
+ {...menuProps}
355
+ className={cx("solara-datepicker__menu", menuProps?.className)}>
356
+ <DatePickerPanel
357
+ {...panelProps}
358
+ variant="menu"
359
+ onSelect={handleSelect}
360
+ />
361
+ </Menu>
362
+ </MenuDropdown>
363
+ );
364
+ }
365
+
366
+ function DatePickerDayButton({
367
+ className,
368
+ day,
369
+ modifiers,
370
+ ...props
371
+ }: React.ComponentProps<typeof DayButton>) {
372
+ const defaultClassNames = getDefaultClassNames();
373
+ const ref = React.useRef<HTMLButtonElement>(null);
374
+
375
+ React.useEffect(() => {
376
+ // Preserve keyboard focus behavior expected by DayPicker.
377
+ if (modifiers.focused) ref.current?.focus();
378
+ }, [modifiers.focused]);
379
+
380
+ return (
381
+ <button
382
+ ref={ref}
383
+ data-day={day.date.toLocaleDateString()}
384
+ data-selected={modifiers.selected ? "true" : "false"}
385
+ className={cx("solara-datepicker__day-button", defaultClassNames.day, className)}
386
+ {...props}
387
+ />
388
+ );
389
+ }
390
+
391
+ function DatePickerMonthCaption({
392
+ calendarMonth,
393
+ displayIndex: _displayIndex,
394
+ className,
395
+ ...props
396
+ }: MonthCaptionProps) {
397
+ // Header owns month navigation and mode toggles.
398
+ const { goToMonth, previousMonth, nextMonth } = useDayPicker();
399
+ const headerView = React.useContext(DatePickerHeaderViewContext);
400
+ if (!headerView) {
401
+ return (
402
+ <div
403
+ className={cx("solara-datepicker__caption", className)}
404
+ {...props}
405
+ />
406
+ );
407
+ }
408
+ const { activePanel, setActivePanel, setYearPageStart } = headerView;
409
+
410
+ const monthDate = calendarMonth.date;
411
+ const monthIndex = monthDate.getMonth();
412
+ const year = monthDate.getFullYear();
413
+ const monthLabel = monthDate.toLocaleString("default", { month: "long" });
414
+ const yearLabel = String(year);
415
+
416
+ React.useEffect(() => {
417
+ // Recenter the year page whenever the visible year changes.
418
+ if (activePanel === "year") setYearPageStart(getCenteredYearPageStart(year));
419
+ }, [activePanel, setYearPageStart, year]);
420
+
421
+ const togglePanel = React.useCallback(
422
+ (panel: Exclude<DatePickerHeaderPanel, null>) => {
423
+ // Clicking the active mode again returns to the default day grid.
424
+ setActivePanel((prev) => (prev === panel ? null : panel));
425
+ },
426
+ [setActivePanel]
427
+ );
428
+
429
+ return (
430
+ <div
431
+ className={cx("solara-datepicker__caption", className)}
432
+ {...props}>
433
+ <div className="solara-datepicker__caption-controls">
434
+ <button
435
+ type="button"
436
+ className="solara-datepicker__header-nav-button"
437
+ onClick={() => previousMonth && goToMonth(previousMonth)}
438
+ disabled={!previousMonth}
439
+ aria-label="Go to previous month">
440
+
441
+ </button>
442
+ <div className="solara-datepicker__caption-title-buttons">
443
+ <button
444
+ type="button"
445
+ className={cx(
446
+ "solara-datepicker__caption-button",
447
+ activePanel === "month" && "solara-datepicker__caption-button--active"
448
+ )}
449
+ aria-haspopup="grid"
450
+ aria-expanded={activePanel === "month"}
451
+ onClick={() => togglePanel("month")}>
452
+ {monthLabel}
453
+ </button>
454
+ <button
455
+ type="button"
456
+ className={cx(
457
+ "solara-datepicker__caption-button",
458
+ activePanel === "year" && "solara-datepicker__caption-button--active"
459
+ )}
460
+ aria-haspopup="grid"
461
+ aria-expanded={activePanel === "year"}
462
+ onClick={() => togglePanel("year")}>
463
+ {yearLabel}
464
+ </button>
465
+ </div>
466
+ <button
467
+ type="button"
468
+ className="solara-datepicker__header-nav-button"
469
+ onClick={() => nextMonth && goToMonth(nextMonth)}
470
+ disabled={!nextMonth}
471
+ aria-label="Go to next month">
472
+
473
+ </button>
474
+ </div>
475
+ </div>
476
+ );
477
+ }
478
+
479
+ function DatePickerHeaderPanelView() {
480
+ // Body area switches between month/year grids while header stays fixed.
481
+ const { goToMonth, months } = useDayPicker();
482
+ const headerView = React.useContext(DatePickerHeaderViewContext);
483
+ if (!headerView) return null;
484
+
485
+ const { activePanel, setActivePanel, yearPageStart, setYearPageStart } = headerView;
486
+ const visibleMonth = months[0]?.date ?? new Date();
487
+ const monthIndex = visibleMonth.getMonth();
488
+ const year = visibleMonth.getFullYear();
489
+
490
+ if (!activePanel) return null;
491
+
492
+ // Selecting from month/year grids always updates visible month then returns
493
+ // to the default day-grid view.
494
+ const selectMonth = (nextMonthIndex: number) => {
495
+ goToMonth(new Date(year, nextMonthIndex, 1));
496
+ setActivePanel(null);
497
+ };
498
+
499
+ const selectYear = (nextYear: number) => {
500
+ goToMonth(new Date(nextYear, monthIndex, 1));
501
+ setActivePanel(null);
502
+ };
503
+
504
+ const yearOptions = getYearPageOptions(yearPageStart);
505
+
506
+ return activePanel === "month" ? (
507
+ <div className="solara-datepicker__month-grid" role="grid" aria-label="Choose month">
508
+ {Array.from({ length: 12 }, (_, i) => {
509
+ const label = new Date(year, i, 1).toLocaleString("default", { month: "short" });
510
+ return (
511
+ <button
512
+ key={label}
513
+ type="button"
514
+ className={cx(
515
+ "solara-datepicker__panel-option",
516
+ i === monthIndex && "solara-datepicker__panel-option--active"
517
+ )}
518
+ onClick={() => selectMonth(i)}>
519
+ {label}
520
+ </button>
521
+ );
522
+ })}
523
+ </div>
524
+ ) : (
525
+ <>
526
+ <div className="solara-datepicker__year-nav">
527
+ <button
528
+ type="button"
529
+ className="solara-datepicker__panel-nav-button"
530
+ onClick={() => setYearPageStart((prev) => prev - YEAR_PAGE_SIZE)}>
531
+
532
+ </button>
533
+ <span className="solara-datepicker__year-nav-label">
534
+ {yearPageStart} - {yearPageStart + (YEAR_PAGE_SIZE - 1)}
535
+ </span>
536
+ <button
537
+ type="button"
538
+ className="solara-datepicker__panel-nav-button"
539
+ onClick={() => setYearPageStart((prev) => prev + YEAR_PAGE_SIZE)}>
540
+
541
+ </button>
542
+ </div>
543
+ <div className="solara-datepicker__year-grid" role="grid" aria-label="Choose year">
544
+ {yearOptions.map((optionYear) => (
545
+ <button
546
+ key={optionYear}
547
+ type="button"
548
+ className={cx(
549
+ "solara-datepicker__panel-option",
550
+ optionYear === year && "solara-datepicker__panel-option--active"
551
+ )}
552
+ onClick={() => selectYear(optionYear)}>
553
+ {optionYear}
554
+ </button>
555
+ ))}
556
+ </div>
557
+ </>
558
+ );
559
+ }
@@ -0,0 +1,100 @@
1
+ import type {
2
+ DateRange,
3
+ OnSelectHandler,
4
+ PropsBase,
5
+ PropsMulti,
6
+ PropsMultiRequired,
7
+ PropsRange,
8
+ PropsRangeRequired,
9
+ PropsSingle,
10
+ PropsSingleRequired,
11
+ } from "react-day-picker";
12
+ import type { MenuDropdownProps, MenuProps } from "@solara/menu";
13
+
14
+ export type DatePickerVariant = "menu" | "standalone";
15
+
16
+ export type DatePickerSelection = Date | Date[] | DateRange | undefined;
17
+
18
+ type DatePickerPanelBaseProps = Omit<PropsBase, "mode" | "required"> & {
19
+ /**
20
+ * Controls spacing + surface styling for the calendar surface.
21
+ */
22
+ variant?: DatePickerVariant;
23
+ };
24
+
25
+ type DatePickerSingleProps = Omit<PropsSingle, "mode"> & {
26
+ mode?: "single";
27
+ };
28
+
29
+ type DatePickerSingleRequiredProps = Omit<PropsSingleRequired, "mode"> & {
30
+ mode?: "single";
31
+ };
32
+
33
+ type DatePickerUncontrolledProps = {
34
+ mode?: undefined;
35
+ required?: undefined;
36
+ selected?: undefined;
37
+ onSelect?: OnSelectHandler<DatePickerSelection>;
38
+ };
39
+
40
+ export type DatePickerPanelProps = DatePickerPanelBaseProps &
41
+ (
42
+ | DatePickerSingleProps
43
+ | DatePickerSingleRequiredProps
44
+ | PropsMulti
45
+ | PropsMultiRequired
46
+ | PropsRange
47
+ | PropsRangeRequired
48
+ | DatePickerUncontrolledProps
49
+ );
50
+
51
+ export type DatePickerProps = DatePickerPanelProps & {
52
+ /**
53
+ * Optional trigger to open the date picker menu.
54
+ */
55
+ trigger?: MenuDropdownProps["trigger"];
56
+ /**
57
+ * Placeholder label used by the default trigger.
58
+ */
59
+ placeholder?: string;
60
+ /**
61
+ * Format the selected value into a trigger label.
62
+ */
63
+ formatLabel?: (value: DatePickerSelection) => string;
64
+ /**
65
+ * Menu placement (align) for the dropdown.
66
+ */
67
+ align?: MenuDropdownProps["align"];
68
+ /**
69
+ * Menu placement (side) for the dropdown.
70
+ */
71
+ side?: MenuDropdownProps["side"];
72
+ /**
73
+ * Controlled open state for the dropdown.
74
+ */
75
+ isOpen?: MenuDropdownProps["isOpen"];
76
+ /**
77
+ * Default open state for the dropdown.
78
+ */
79
+ defaultOpen?: MenuDropdownProps["defaultOpen"];
80
+ /**
81
+ * Notify when the dropdown opens or closes.
82
+ */
83
+ onOpenChange?: MenuDropdownProps["onOpenChange"];
84
+ /**
85
+ * Close the dropdown when a date is selected.
86
+ */
87
+ closeOnSelect?: boolean;
88
+ /**
89
+ * Props passed to the Menu surface wrapper.
90
+ */
91
+ menuProps?: Omit<MenuProps, "children">;
92
+ /**
93
+ * Accessible label for the dropdown content.
94
+ */
95
+ contentAriaLabel?: MenuDropdownProps["contentAriaLabel"];
96
+ /**
97
+ * Custom class name for the trigger button.
98
+ */
99
+ triggerClassName?: string;
100
+ };
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { DatePicker, DatePickerPanel } from "./DatePicker";
2
+ export type { DatePickerProps, DatePickerPanelProps, DatePickerVariant } from "./DatePicker.types";