@stackshift-ui/date-picker 1.0.0-beta.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/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@stackshift-ui/date-picker",
3
+ "description": "A date picker component with range and presets.",
4
+ "version": "1.0.0-beta.1",
5
+ "private": false,
6
+ "sideEffects": false,
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.mjs",
9
+ "types": "./dist/index.d.ts",
10
+ "files": [
11
+ "dist/**",
12
+ "src"
13
+ ],
14
+ "author": "WebriQ <info@webriq.com>",
15
+ "devDependencies": {
16
+ "@testing-library/jest-dom": "^6.5.0",
17
+ "@testing-library/react": "^16.0.1",
18
+ "@testing-library/user-event": "^14.6.1",
19
+ "@types/node": "^22.7.0",
20
+ "@types/react": "^18.3.9",
21
+ "@types/react-dom": "^18.3.0",
22
+ "@vitejs/plugin-react": "^4.3.1",
23
+ "@vitest/coverage-v8": "^2.1.1",
24
+ "esbuild-plugin-rdi": "^0.0.0",
25
+ "esbuild-plugin-react18": "^0.2.5",
26
+ "esbuild-plugin-react18-css": "^0.0.4",
27
+ "jsdom": "^25.0.1",
28
+ "react": "^18.3.1",
29
+ "react-dom": "^18.3.1",
30
+ "tsup": "^8.3.0",
31
+ "typescript": "^5.6.2",
32
+ "vite-tsconfig-paths": "^5.0.1",
33
+ "vitest": "^2.1.1",
34
+ "@stackshift-ui/typescript-config": "6.0.10",
35
+ "@stackshift-ui/eslint-config": "6.0.10"
36
+ },
37
+ "dependencies": {
38
+ "classnames": "^2.5.1",
39
+ "date-fns": "^4.1.0",
40
+ "lucide-react": "^0.468.0",
41
+ "@stackshift-ui/button": "6.1.0-beta.0",
42
+ "@stackshift-ui/scripts": "6.1.0-beta.0",
43
+ "@stackshift-ui/popover": "1.0.0-beta.1",
44
+ "@stackshift-ui/system": "6.1.0-beta.0",
45
+ "@stackshift-ui/calendar": "1.0.0-beta.1",
46
+ "@stackshift-ui/input": "7.0.0-beta.0",
47
+ "@stackshift-ui/label": "1.0.0-beta.1"
48
+ },
49
+ "peerDependencies": {
50
+ "@stackshift-ui/system": ">=6.1.0-beta.0",
51
+ "@types/react": "16.8 - 19",
52
+ "next": "10 - 14",
53
+ "react": "16.8 - 19",
54
+ "react-dom": "16.8 - 19"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "next": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "scripts": {
62
+ "build": "tsup && tsc -p tsconfig-build.json",
63
+ "clean": "rm -rf dist",
64
+ "dev": "tsup --watch && tsc -p tsconfig-build.json -w",
65
+ "typecheck": "tsc --noEmit",
66
+ "lint": "eslint src/",
67
+ "test": "vitest run --coverage"
68
+ }
69
+ }
@@ -0,0 +1,289 @@
1
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, describe, test, vi } from "vitest";
4
+ import { DatePicker } from "./date-picker";
5
+
6
+ describe("DatePicker", () => {
7
+ afterEach(cleanup);
8
+
9
+ test("Common: DatePicker - test if renders without errors", ({ expect }) => {
10
+ const { unmount } = render(<DatePicker data-testid="date-picker" label="Select a date" />);
11
+ expect(screen.getByText("Select a date")).toBeInTheDocument();
12
+ unmount();
13
+ });
14
+
15
+ test("Common: DatePicker - test if renders with correct label", ({ expect }) => {
16
+ const { unmount } = render(<DatePicker data-testid="date-picker" label="Choose your date" />);
17
+ expect(screen.getByText("Choose your date")).toBeInTheDocument();
18
+ unmount();
19
+ });
20
+
21
+ test("Common: DatePicker - test if renders with default date", ({ expect }) => {
22
+ const defaultDate = new Date(2024, 0, 15);
23
+ const { unmount } = render(<DatePicker selectedDate={defaultDate} label="Select date" />);
24
+ expect(screen.getByText(defaultDate.toLocaleDateString())).toBeInTheDocument();
25
+ unmount();
26
+ });
27
+
28
+ test("Common: DatePicker - test if shows current date when no date is provided", ({ expect }) => {
29
+ const { unmount } = render(<DatePicker label="Select date" />);
30
+ const today = new Date();
31
+ expect(screen.getByText(today.toLocaleDateString())).toBeInTheDocument();
32
+ unmount();
33
+ });
34
+
35
+ test("Common: DatePicker - test if opens calendar when trigger is clicked", async ({
36
+ expect,
37
+ }) => {
38
+ const user = userEvent.setup();
39
+ const { unmount } = render(<DatePicker label="Select date" />);
40
+
41
+ const trigger = screen.getByRole("button");
42
+ await user.click(trigger);
43
+
44
+ await waitFor(() => {
45
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
46
+ });
47
+ unmount();
48
+ });
49
+
50
+ test("Common: DatePicker - test if closes calendar when clicking outside", async ({ expect }) => {
51
+ const user = userEvent.setup();
52
+ const { unmount } = render(
53
+ <div>
54
+ <DatePicker label="Select date" />
55
+ <button>Outside button</button>
56
+ </div>,
57
+ );
58
+
59
+ const trigger = screen.getByRole("button", { name: /select date/i });
60
+ await user.click(trigger);
61
+
62
+ await waitFor(() => {
63
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
64
+ });
65
+
66
+ const outsideButton = screen.getByRole("button", { name: /outside/i });
67
+ await user.click(outsideButton);
68
+
69
+ await waitFor(() => {
70
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
71
+ });
72
+
73
+ unmount();
74
+ });
75
+
76
+ test("Common: DatePicker - test if calls onSelect when calendar day is clicked", async ({
77
+ expect,
78
+ }) => {
79
+ const user = userEvent.setup();
80
+ const onSelect = vi.fn();
81
+
82
+ const { unmount } = render(<DatePicker label="Select date" onSelect={onSelect} />);
83
+
84
+ const trigger = screen.getByRole("button");
85
+ await user.click(trigger);
86
+
87
+ await waitFor(() => {
88
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
89
+ });
90
+
91
+ // Find all day buttons and click one that's not disabled
92
+ const dayButtons = screen
93
+ .getAllByRole("button")
94
+ .filter(
95
+ btn => btn.textContent && /^\d+$/.test(btn.textContent) && !btn.hasAttribute("disabled"),
96
+ );
97
+
98
+ if (dayButtons.length > 0) {
99
+ await user.click(dayButtons[0]);
100
+ expect(onSelect).toHaveBeenCalled();
101
+ }
102
+ unmount();
103
+ });
104
+
105
+ // Keyboard navigation tests
106
+ test("Common: DatePicker - test if opens calendar with Enter key", async ({ expect }) => {
107
+ const user = userEvent.setup();
108
+ const { unmount } = render(<DatePicker label="Select date" />);
109
+
110
+ const trigger = screen.getByRole("button");
111
+ trigger.focus();
112
+ await user.keyboard("{Enter}");
113
+
114
+ await waitFor(() => {
115
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
116
+ });
117
+ unmount();
118
+ });
119
+
120
+ test("Common: DatePicker - test if opens calendar with Space key", async ({ expect }) => {
121
+ const user = userEvent.setup();
122
+ const { unmount } = render(<DatePicker label="Select date" />);
123
+
124
+ const trigger = screen.getByRole("button");
125
+ trigger.focus();
126
+ await user.keyboard(" ");
127
+
128
+ await waitFor(() => {
129
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
130
+ });
131
+ unmount();
132
+ });
133
+
134
+ test("Common: DatePicker - test if closes calendar with Escape key", async ({ expect }) => {
135
+ const user = userEvent.setup();
136
+ const { unmount } = render(<DatePicker label="Select date" />);
137
+
138
+ const trigger = screen.getByRole("button");
139
+ await user.click(trigger);
140
+
141
+ await waitFor(() => {
142
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
143
+ });
144
+
145
+ await user.keyboard("{Escape}");
146
+
147
+ await waitFor(() => {
148
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
149
+ });
150
+ unmount();
151
+ });
152
+
153
+ test("Common: DatePicker - test if displays date in correct format", ({ expect }) => {
154
+ const testDate = new Date(2024, 5, 15); // June 15, 2024
155
+ const { unmount } = render(<DatePicker selectedDate={testDate} label="Select date" />);
156
+
157
+ const expectedFormat = testDate.toLocaleDateString();
158
+ expect(screen.getByText(expectedFormat)).toBeInTheDocument();
159
+ unmount();
160
+ });
161
+
162
+ test("Common: DatePicker - test if has proper ARIA attributes", ({ expect }) => {
163
+ const { unmount } = render(<DatePicker label="Select date" />);
164
+
165
+ const trigger = screen.getByRole("button");
166
+ expect(trigger).toHaveAttribute("aria-haspopup", "dialog");
167
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
168
+ unmount();
169
+ });
170
+
171
+ test("Common: DatePicker - test if updates ARIA expanded when calendar opens", async ({
172
+ expect,
173
+ }) => {
174
+ const user = userEvent.setup();
175
+ const { unmount } = render(<DatePicker label="Select date" />);
176
+
177
+ const trigger = screen.getByRole("button");
178
+ await user.click(trigger);
179
+
180
+ await waitFor(() => {
181
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
182
+ });
183
+ unmount();
184
+ });
185
+
186
+ test("Common: DatePicker - test if has proper label association", ({ expect }) => {
187
+ const { unmount } = render(<DatePicker label="Birth date" />);
188
+
189
+ const trigger = screen.getByRole("button");
190
+ const label = screen.getByText("Birth date");
191
+
192
+ expect(trigger).toHaveAttribute("id", "date");
193
+ expect(label).toHaveAttribute("for", "date");
194
+ unmount();
195
+ });
196
+
197
+ test("Common: DatePicker - test if handles leap year dates correctly", ({ expect }) => {
198
+ const leapYearDate = new Date(2024, 1, 29); // Feb 29, 2024
199
+ const { unmount } = render(<DatePicker selectedDate={leapYearDate} label="Select date" />);
200
+
201
+ const expectedFormat = leapYearDate.toLocaleDateString();
202
+ expect(screen.getByText(expectedFormat)).toBeInTheDocument();
203
+ });
204
+
205
+ test("Common: DatePicker - test if cleans up event listeners on unmount", ({ expect }) => {
206
+ const { unmount } = render(<DatePicker label="Select date" />);
207
+
208
+ expect(() => unmount()).not.toThrow();
209
+ });
210
+
211
+ test("Common: DatePicker - test if handles rapid clicks without errors", async ({ expect }) => {
212
+ const user = userEvent.setup();
213
+ const onSelect = vi.fn();
214
+
215
+ const { unmount } = render(<DatePicker label="Select date" onSelect={onSelect} />);
216
+
217
+ const trigger = screen.getByRole("button");
218
+
219
+ await user.click(trigger);
220
+ await user.click(trigger);
221
+ await user.click(trigger);
222
+
223
+ await waitFor(() => {
224
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
225
+ });
226
+ unmount();
227
+ });
228
+
229
+ test("Common: DatePicker - test if passes through additional props", ({ expect }) => {
230
+ const { unmount } = render(
231
+ <DatePicker label="Select date" data-testid="custom-picker" className="custom-class" />,
232
+ );
233
+
234
+ const container = screen.getByTestId("custom-picker");
235
+ expect(container).toBeInTheDocument();
236
+ expect(container).toHaveClass("custom-class");
237
+ unmount();
238
+ });
239
+
240
+ test("Common: DatePicker - test if handles different date selections", async ({ expect }) => {
241
+ const user = userEvent.setup();
242
+ const onSelect = vi.fn();
243
+
244
+ const { unmount } = render(<DatePicker label="Select date" onSelect={onSelect} />);
245
+
246
+ const trigger = screen.getByRole("button");
247
+ await user.click(trigger);
248
+
249
+ await waitFor(() => {
250
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
251
+ });
252
+
253
+ const dayButtons = screen
254
+ .getAllByRole("button")
255
+ .filter(
256
+ btn => btn.textContent && /^\d+$/.test(btn.textContent) && !btn.hasAttribute("disabled"),
257
+ );
258
+
259
+ if (dayButtons.length > 1) {
260
+ await user.click(dayButtons[0]);
261
+ expect(onSelect).toHaveBeenCalledTimes(1);
262
+
263
+ await waitFor(
264
+ () => {
265
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
266
+ },
267
+ { timeout: 2000 },
268
+ );
269
+
270
+ await user.click(trigger);
271
+
272
+ await waitFor(() => {
273
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
274
+ });
275
+
276
+ const newDayButtons = screen
277
+ .getAllByRole("button")
278
+ .filter(
279
+ btn => btn.textContent && /^\d+$/.test(btn.textContent) && !btn.hasAttribute("disabled"),
280
+ );
281
+
282
+ if (newDayButtons.length > 1) {
283
+ await user.click(newDayButtons[1]);
284
+ expect(onSelect).toHaveBeenCalledTimes(2);
285
+ }
286
+ }
287
+ unmount();
288
+ });
289
+ });
@@ -0,0 +1,228 @@
1
+ "use client";
2
+
3
+ import { Calendar as CalendarIcon, ChevronDownIcon } from "lucide-react";
4
+ import * as React from "react";
5
+
6
+ import { Button } from "@stackshift-ui/button";
7
+ import { Calendar } from "@stackshift-ui/calendar";
8
+ import { Input } from "@stackshift-ui/input";
9
+ import { Label } from "@stackshift-ui/label";
10
+ import { Popover, PopoverContent, PopoverTrigger } from "@stackshift-ui/popover";
11
+ import { cn, DefaultComponent, useStackShiftUIComponents } from "@stackshift-ui/system";
12
+
13
+ const displayName = "DatePicker";
14
+ const displayNameInput = "DatePickerInput";
15
+ const displayNameTime = "DatePickerTime";
16
+
17
+ export interface DatePickerProps {
18
+ label?: string;
19
+ mode?: "single" | "multiple" | "range" | undefined;
20
+ selectedDate?: Date;
21
+ onSelect?: (date: Date) => void;
22
+ required?: boolean;
23
+ min?: number;
24
+ max?: number;
25
+ className?: string;
26
+ }
27
+
28
+ export function DatePicker({
29
+ label,
30
+ mode = "range",
31
+ selectedDate,
32
+ onSelect,
33
+ ...props
34
+ }: DatePickerProps) {
35
+ const { [displayName]: Component = DefaultComponent } = useStackShiftUIComponents();
36
+
37
+ const [open, setOpen] = React.useState(false);
38
+ const date = selectedDate || new Date();
39
+
40
+ const handleSelect = (date: Date) => {
41
+ setOpen(false);
42
+ onSelect?.(date);
43
+ };
44
+
45
+ return (
46
+ <Component className={cn("flex flex-col gap-3", props.className)} {...props}>
47
+ <Label htmlFor="date" className="px-1">
48
+ {label}
49
+ </Label>
50
+ <Popover open={open} onOpenChange={setOpen}>
51
+ <PopoverTrigger asChild>
52
+ <Button variant="outline" id="date" className="w-48 justify-between font-normal">
53
+ {date ? date.toLocaleDateString() : "Select date"}
54
+ <ChevronDownIcon />
55
+ </Button>
56
+ </PopoverTrigger>
57
+ <PopoverContent className="w-auto overflow-hidden p-0" align="start">
58
+ {/* @ts-ignore-error */}
59
+ <Calendar mode={mode} selected={date} captionLayout="dropdown" onSelect={handleSelect} />
60
+ </PopoverContent>
61
+ </Popover>
62
+ </Component>
63
+ );
64
+ }
65
+ DatePicker.displayName = displayName;
66
+
67
+ function formatDate(date: Date | undefined) {
68
+ if (!date) {
69
+ return "";
70
+ }
71
+ return date.toLocaleDateString("en-US", {
72
+ day: "2-digit",
73
+ month: "long",
74
+ year: "numeric",
75
+ });
76
+ }
77
+ function isValidDate(date: Date | undefined) {
78
+ if (!date) {
79
+ return false;
80
+ }
81
+ return !isNaN(date.getTime());
82
+ }
83
+
84
+ export interface DatePickerInputProps extends Omit<DatePickerProps, "mode"> {
85
+ selectedMonth?: Date;
86
+ onMonthChange?: (date: Date) => void;
87
+ }
88
+
89
+ export function DatePickerInput({
90
+ label,
91
+ selectedDate,
92
+ onSelect,
93
+ selectedMonth,
94
+ onMonthChange,
95
+ ...props
96
+ }: DatePickerInputProps) {
97
+ const { [displayNameInput]: Component = DefaultComponent } = useStackShiftUIComponents();
98
+
99
+ const [open, setOpen] = React.useState(false);
100
+ const [date, setDate] = React.useState(selectedDate || new Date());
101
+ const [month, setMonth] = React.useState(selectedMonth || date);
102
+ const [value, setValue] = React.useState(formatDate(date));
103
+
104
+ const handleSelect = (date: Date | undefined) => {
105
+ if (!date) return;
106
+ setDate(date);
107
+ setValue(formatDate(date));
108
+ setOpen(false);
109
+ };
110
+
111
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
112
+ const date = new Date(e.target.value);
113
+ setValue(e.target.value);
114
+ if (isValidDate(date)) {
115
+ setDate(date);
116
+ setMonth(date);
117
+ onSelect?.(date);
118
+ onMonthChange?.(date);
119
+ }
120
+ };
121
+
122
+ return (
123
+ <Component className="flex flex-col gap-3" {...props}>
124
+ <Label htmlFor="date" className="px-1">
125
+ {label}
126
+ </Label>
127
+ <div className="relative flex gap-2">
128
+ <Input
129
+ id="date"
130
+ value={value}
131
+ placeholder="June 01, 2025"
132
+ className="bg-background pr-10"
133
+ onChange={handleInputChange}
134
+ onKeyDown={e => {
135
+ if (e.key === "ArrowDown") {
136
+ e.preventDefault();
137
+ setOpen(true);
138
+ }
139
+ }}
140
+ />
141
+ <Popover open={open} onOpenChange={setOpen}>
142
+ <PopoverTrigger asChild>
143
+ <Button
144
+ id="date-picker"
145
+ variant="ghost"
146
+ className="absolute top-1/2 right-2 size-6 -translate-y-1/2">
147
+ <CalendarIcon className="size-3.5" />
148
+ <span className="sr-only">Select date</span>
149
+ </Button>
150
+ </PopoverTrigger>
151
+ <PopoverContent
152
+ className="w-auto overflow-hidden p-0"
153
+ align="end"
154
+ alignOffset={-8}
155
+ sideOffset={10}>
156
+ <Calendar
157
+ mode="single"
158
+ selected={date}
159
+ captionLayout="dropdown"
160
+ month={month}
161
+ onMonthChange={setMonth}
162
+ onSelect={handleSelect}
163
+ />
164
+ </PopoverContent>
165
+ </Popover>
166
+ </div>
167
+ </Component>
168
+ );
169
+ }
170
+ DatePickerInput.displayName = displayNameInput;
171
+
172
+ export interface DatePickerTimeProps extends Omit<DatePickerProps, "mode"> {
173
+ selectedTime?: string;
174
+ onTimeChange?: (time: string) => void;
175
+ }
176
+
177
+ export function DatePickerTime({ label, selectedDate, onSelect, ...props }: DatePickerTimeProps) {
178
+ const { [displayNameTime]: Component = DefaultComponent } = useStackShiftUIComponents();
179
+
180
+ const [open, setOpen] = React.useState(false);
181
+ const [date, setDate] = React.useState<Date | undefined>(selectedDate);
182
+
183
+ const handleSelect = (date: Date | undefined) => {
184
+ setDate(date);
185
+ setOpen(false);
186
+ if (!date) return;
187
+ onSelect?.(date);
188
+ };
189
+
190
+ return (
191
+ <Component className="flex gap-4" {...props}>
192
+ <div className="flex flex-col gap-3">
193
+ <Label htmlFor="date-picker" className="px-1">
194
+ {label}
195
+ </Label>
196
+ <Popover open={open} onOpenChange={setOpen}>
197
+ <PopoverTrigger asChild>
198
+ <Button variant="outline" id="date-picker" className="w-32 justify-between font-normal">
199
+ {date ? date.toLocaleDateString() : "Select date"}
200
+ <ChevronDownIcon />
201
+ </Button>
202
+ </PopoverTrigger>
203
+ <PopoverContent className="w-auto overflow-hidden p-0" align="start">
204
+ <Calendar
205
+ mode="single"
206
+ selected={date}
207
+ captionLayout="dropdown"
208
+ onSelect={handleSelect}
209
+ />
210
+ </PopoverContent>
211
+ </Popover>
212
+ </div>
213
+ <div className="flex flex-col gap-3">
214
+ <Label htmlFor="time-picker" className="px-1">
215
+ Time
216
+ </Label>
217
+ <Input
218
+ /* @ts-ignore-error */
219
+ type="time"
220
+ id="time-picker"
221
+ step="1"
222
+ defaultValue="10:30:00"
223
+ className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
224
+ />
225
+ </div>
226
+ </Component>
227
+ );
228
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ "use client";
2
+
3
+ // component exports
4
+ export * from "./date-picker";
@@ -0,0 +1,4 @@
1
+ import '@testing-library/jest-dom';
2
+
3
+ export { };
4
+