@varialkit/datepicker 0.1.0
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 +61 -0
- package/examples.tsx +93 -0
- package/package.json +27 -0
- package/src/DatePicker.scss +414 -0
- package/src/DatePicker.tsx +559 -0
- package/src/DatePicker.types.ts +100 -0
- package/src/index.ts +2 -0
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.0",
|
|
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.0"
|
|
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