@snapdragonsnursery/react-components 1.1.38 → 1.3.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.
@@ -0,0 +1,237 @@
1
+ // Child Search Filters Component
2
+ // Provides advanced filtering capabilities for child search including status, site, date of birth, age, and sorting options
3
+ // Usage: <ChildSearchFilters filters={filters} onFiltersChange={setFilters} sites={sites} />
4
+
5
+ import React from "react";
6
+ import { Input } from "./ui/input";
7
+ import { Select, SelectOption } from "./ui/select";
8
+ import { DateRangePicker } from "./ui/date-range-picker";
9
+ import { Button } from "./ui/button";
10
+ import {
11
+ FunnelIcon,
12
+ ChevronDownIcon,
13
+ ChevronUpIcon,
14
+ } from "@heroicons/react/24/outline";
15
+
16
+ const ChildSearchFilters = ({
17
+ filters,
18
+ onFiltersChange,
19
+ sites = null,
20
+ activeOnly = true,
21
+ isAdvancedFiltersOpen,
22
+ onToggleAdvancedFilters,
23
+ onClearFilters,
24
+ onApplyFilters,
25
+ }) => {
26
+ // Local state for filters that haven't been applied yet
27
+ const [localFilters, setLocalFilters] = React.useState(filters);
28
+ const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
29
+
30
+ // Update local filters when props change
31
+ React.useEffect(() => {
32
+ setLocalFilters(filters);
33
+ setHasUnsavedChanges(false);
34
+ }, [filters]);
35
+ // Convert existing date strings to DateRangePicker format
36
+ const getDateRange = () => {
37
+ if (localFilters.dobFrom || localFilters.dobTo) {
38
+ return {
39
+ from: localFilters.dobFrom ? new Date(localFilters.dobFrom) : null,
40
+ to: localFilters.dobTo ? new Date(localFilters.dobTo) : null,
41
+ };
42
+ }
43
+ return null;
44
+ };
45
+
46
+ const handleDateRangeChange = (range) => {
47
+ // DateRangePicker now only calls onSelect when both dates are selected or when cleared
48
+ if (range?.from && range?.to) {
49
+ // Both dates selected - update local filters
50
+ setLocalFilters(prev => ({
51
+ ...prev,
52
+ dobFrom: range.from.toISOString().split('T')[0],
53
+ dobTo: range.to.toISOString().split('T')[0],
54
+ }));
55
+ setHasUnsavedChanges(true);
56
+ } else if (!range?.from && !range?.to) {
57
+ // Range cleared - clear local filters
58
+ setLocalFilters(prev => ({
59
+ ...prev,
60
+ dobFrom: '',
61
+ dobTo: '',
62
+ }));
63
+ setHasUnsavedChanges(true);
64
+ }
65
+ // No need to handle single date selection as DateRangePicker handles it internally
66
+ };
67
+
68
+ const handleFilterChange = (key, value) => {
69
+ setLocalFilters(prev => ({
70
+ ...prev,
71
+ [key]: value,
72
+ }));
73
+ setHasUnsavedChanges(true);
74
+ };
75
+
76
+ const clearFilters = () => {
77
+ onClearFilters();
78
+ };
79
+
80
+ const handleApplyFilters = () => {
81
+ onApplyFilters(localFilters);
82
+ setHasUnsavedChanges(false);
83
+ };
84
+
85
+ const handleCancelChanges = () => {
86
+ setLocalFilters(filters);
87
+ setHasUnsavedChanges(false);
88
+ };
89
+
90
+ return (
91
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 mb-6">
92
+ <div className="p-6">
93
+ {/* Advanced Filters Toggle */}
94
+ <div className="flex items-center justify-between">
95
+ <button
96
+ onClick={onToggleAdvancedFilters}
97
+ className="flex items-center space-x-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
98
+ >
99
+ <FunnelIcon className="h-4 w-4" />
100
+ <span>Advanced Filters</span>
101
+ {isAdvancedFiltersOpen ? (
102
+ <ChevronUpIcon className="h-4 w-4" />
103
+ ) : (
104
+ <ChevronDownIcon className="h-4 w-4" />
105
+ )}
106
+ </button>
107
+ <button
108
+ onClick={clearFilters}
109
+ className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
110
+ >
111
+ Clear Filters
112
+ </button>
113
+ </div>
114
+
115
+ {/* Advanced Filters */}
116
+ {isAdvancedFiltersOpen && (
117
+ <div
118
+ className="mt-4 p-4 rounded-lg"
119
+ style={{
120
+ backgroundColor: 'hsl(var(--card))',
121
+ border: '1px solid hsl(var(--border))'
122
+ }}
123
+ >
124
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
125
+ {/* Status Filter */}
126
+ <div>
127
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
128
+ Status
129
+ </label>
130
+ <Select
131
+ value={localFilters.status}
132
+ onChange={(e) => handleFilterChange("status", e.target.value)}
133
+ className="bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white"
134
+ >
135
+ <SelectOption value="all">All Children</SelectOption>
136
+ <SelectOption value="active">Active Only</SelectOption>
137
+ <SelectOption value="inactive">Inactive Only</SelectOption>
138
+ </Select>
139
+ </div>
140
+
141
+ {/* Site Filter */}
142
+ {sites && sites.length > 0 && (
143
+ <div>
144
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
145
+ Site
146
+ </label>
147
+ <Select
148
+ value={localFilters.selectedSiteId}
149
+ onChange={(e) => handleFilterChange("selectedSiteId", e.target.value)}
150
+ className="bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white"
151
+ >
152
+ <SelectOption value="">All Sites</SelectOption>
153
+ {sites.map((site) => (
154
+ <SelectOption key={site.site_id} value={site.site_id}>
155
+ {site.site_name}
156
+ </SelectOption>
157
+ ))}
158
+ </Select>
159
+ </div>
160
+ )}
161
+
162
+ {/* Date of Birth Range */}
163
+ <div>
164
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
165
+ Date of Birth Range
166
+ </label>
167
+ <DateRangePicker
168
+ selectedRange={getDateRange()}
169
+ onSelect={handleDateRangeChange}
170
+ placeholder="Select date of birth range"
171
+ className="bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white"
172
+ />
173
+ </div>
174
+
175
+ {/* Age Range */}
176
+ <div>
177
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
178
+ Age From (months)
179
+ </label>
180
+ <Input
181
+ type="number"
182
+ min="0"
183
+ value={localFilters.ageFrom}
184
+ onChange={(e) => handleFilterChange("ageFrom", e.target.value)}
185
+ placeholder="0"
186
+ className="bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white"
187
+ />
188
+ </div>
189
+
190
+ <div>
191
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
192
+ Age To (months)
193
+ </label>
194
+ <Input
195
+ type="number"
196
+ min="0"
197
+ value={localFilters.ageTo}
198
+ onChange={(e) => handleFilterChange("ageTo", e.target.value)}
199
+ placeholder="60"
200
+ className="bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white"
201
+ />
202
+ </div>
203
+ </div>
204
+
205
+ {/* Apply/Cancel Buttons */}
206
+ {hasUnsavedChanges && (
207
+ <div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
208
+ <div className="flex items-center justify-between">
209
+ <span className="text-sm text-gray-600 dark:text-gray-400">
210
+ You have unsaved filter changes
211
+ </span>
212
+ <div className="flex items-center space-x-2">
213
+ <Button
214
+ variant="outline"
215
+ size="sm"
216
+ onClick={handleCancelChanges}
217
+ >
218
+ Cancel
219
+ </Button>
220
+ <Button
221
+ size="sm"
222
+ onClick={handleApplyFilters}
223
+ >
224
+ Apply Filters
225
+ </Button>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ )}
230
+ </div>
231
+ )}
232
+ </div>
233
+ </div>
234
+ );
235
+ };
236
+
237
+ export default ChildSearchFilters;
@@ -0,0 +1,280 @@
1
+ // ChildSearchFilters Component Tests
2
+ // Tests the filter functionality including status, site, date of birth, and age options
3
+
4
+ import React from "react";
5
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
6
+ import "@testing-library/jest-dom";
7
+ import ChildSearchFilters from "./ChildSearchFilters";
8
+
9
+ // Mock the UI components
10
+ jest.mock("./ui/input", () => ({
11
+ __esModule: true,
12
+ Input: ({ value, onChange, placeholder, type, min, className, ...props }) => (
13
+ <input
14
+ data-testid="input"
15
+ value={value}
16
+ onChange={onChange}
17
+ placeholder={placeholder}
18
+ type={type}
19
+ min={min}
20
+ className={className}
21
+ {...props}
22
+ />
23
+ ),
24
+ }));
25
+
26
+ jest.mock("./ui/select", () => ({
27
+ __esModule: true,
28
+ Select: ({ value, onChange, children, className, ...props }) => (
29
+ <select
30
+ data-testid="select"
31
+ value={value}
32
+ onChange={onChange}
33
+ className={className}
34
+ {...props}
35
+ >
36
+ {children}
37
+ </select>
38
+ ),
39
+ SelectOption: ({ value, children, ...props }) => (
40
+ <option value={value} {...props}>
41
+ {children}
42
+ </option>
43
+ ),
44
+ }));
45
+
46
+ jest.mock("./ui/date-range-picker", () => ({
47
+ __esModule: true,
48
+ DateRangePicker: ({ selectedRange, onSelect, placeholder, className, ...props }) => (
49
+ <div data-testid="date-range-picker" className={className}>
50
+ <button
51
+ onClick={() => onSelect && onSelect({ from: new Date('2020-01-01'), to: new Date('2020-12-31') })}
52
+ {...props}
53
+ >
54
+ {selectedRange?.from && selectedRange?.to
55
+ ? `${selectedRange.from.toLocaleDateString()} - ${selectedRange.to.toLocaleDateString()}`
56
+ : placeholder || 'Select date range'
57
+ }
58
+ </button>
59
+ </div>
60
+ ),
61
+ }));
62
+
63
+ describe("ChildSearchFilters", () => {
64
+ const mockFilters = {
65
+ status: "active",
66
+ selectedSiteId: "",
67
+ dobFrom: "",
68
+ dobTo: "",
69
+ ageFrom: "",
70
+ ageTo: "",
71
+ };
72
+
73
+ const mockSites = [
74
+ { site_id: 1, site_name: "Site A" },
75
+ { site_id: 2, site_name: "Site B" },
76
+ ];
77
+
78
+ const defaultProps = {
79
+ filters: mockFilters,
80
+ onFiltersChange: jest.fn(),
81
+ onApplyFilters: jest.fn(),
82
+ sites: mockSites,
83
+ activeOnly: true,
84
+ isAdvancedFiltersOpen: false,
85
+ onToggleAdvancedFilters: jest.fn(),
86
+ onClearFilters: jest.fn(),
87
+ };
88
+
89
+ beforeEach(() => {
90
+ jest.clearAllMocks();
91
+ });
92
+
93
+ it("renders the advanced filters toggle button", () => {
94
+ render(<ChildSearchFilters {...defaultProps} />);
95
+
96
+ expect(screen.getByText("Advanced Filters")).toBeInTheDocument();
97
+ expect(screen.getByText("Clear Filters")).toBeInTheDocument();
98
+ });
99
+
100
+ it("shows advanced filters when isAdvancedFiltersOpen is true", () => {
101
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
102
+
103
+ expect(screen.getByText("Status")).toBeInTheDocument();
104
+ expect(screen.getByText("Site")).toBeInTheDocument();
105
+ expect(screen.getByText("Date of Birth Range")).toBeInTheDocument();
106
+ expect(screen.getByText("Age From (months)")).toBeInTheDocument();
107
+ expect(screen.getByText("Age To (months)")).toBeInTheDocument();
108
+ });
109
+
110
+ it("hides advanced filters when isAdvancedFiltersOpen is false", () => {
111
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={false} />);
112
+
113
+ expect(screen.queryByText("Status")).not.toBeInTheDocument();
114
+ expect(screen.queryByText("Site")).not.toBeInTheDocument();
115
+ });
116
+
117
+ it("calls onToggleAdvancedFilters when toggle button is clicked", () => {
118
+ render(<ChildSearchFilters {...defaultProps} />);
119
+
120
+ fireEvent.click(screen.getByText("Advanced Filters"));
121
+
122
+ expect(defaultProps.onToggleAdvancedFilters).toHaveBeenCalledTimes(1);
123
+ });
124
+
125
+ it("calls onClearFilters when clear button is clicked", () => {
126
+ render(<ChildSearchFilters {...defaultProps} />);
127
+
128
+ fireEvent.click(screen.getByText("Clear Filters"));
129
+
130
+ expect(defaultProps.onClearFilters).toHaveBeenCalledTimes(1);
131
+ });
132
+
133
+ it("renders site options when sites are provided", () => {
134
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
135
+
136
+ const siteSelect = screen.getAllByTestId("select")[1]; // Second select is site
137
+ expect(siteSelect).toBeInTheDocument();
138
+
139
+ // Check that site options are rendered
140
+ expect(siteSelect).toHaveValue("");
141
+ expect(siteSelect).toHaveTextContent("All Sites");
142
+ expect(siteSelect).toHaveTextContent("Site A");
143
+ expect(siteSelect).toHaveTextContent("Site B");
144
+ });
145
+
146
+ it("does not render site filter when sites are not provided", () => {
147
+ render(
148
+ <ChildSearchFilters
149
+ {...defaultProps}
150
+ sites={null}
151
+ isAdvancedFiltersOpen={true}
152
+ />
153
+ );
154
+
155
+ const selects = screen.getAllByTestId("select");
156
+ expect(selects).toHaveLength(1); // Only Status filter (no site filter when sites=null)
157
+ });
158
+
159
+ it("shows apply button when filters are changed", () => {
160
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
161
+
162
+ const statusSelect = screen.getAllByTestId("select")[0];
163
+ fireEvent.change(statusSelect, { target: { value: "inactive" } });
164
+
165
+ expect(screen.getByText("Apply Filters")).toBeInTheDocument();
166
+ expect(screen.getByText("You have unsaved filter changes")).toBeInTheDocument();
167
+ });
168
+
169
+ it("calls onApplyFilters when apply button is clicked", () => {
170
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
171
+
172
+ const statusSelect = screen.getAllByTestId("select")[0];
173
+ fireEvent.change(statusSelect, { target: { value: "inactive" } });
174
+
175
+ const applyButton = screen.getByText("Apply Filters");
176
+ fireEvent.click(applyButton);
177
+
178
+ expect(defaultProps.onApplyFilters).toHaveBeenCalledWith(
179
+ expect.objectContaining({
180
+ status: "inactive"
181
+ })
182
+ );
183
+ });
184
+
185
+ it("calls onApplyFilters when apply button is clicked with date range", () => {
186
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
187
+
188
+ const dateRangePicker = screen.getByTestId("date-range-picker");
189
+ const button = dateRangePicker.querySelector("button");
190
+ fireEvent.click(button);
191
+
192
+ const applyButton = screen.getByText("Apply Filters");
193
+ fireEvent.click(applyButton);
194
+
195
+ expect(defaultProps.onApplyFilters).toHaveBeenCalledWith(
196
+ expect.objectContaining({
197
+ dobFrom: "2020-01-01",
198
+ dobTo: "2020-12-31"
199
+ })
200
+ );
201
+ });
202
+
203
+ it("calls onApplyFilters when apply button is clicked with age filter", () => {
204
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
205
+
206
+ const ageFromInput = screen.getAllByTestId("input")[0];
207
+ fireEvent.change(ageFromInput, { target: { value: "12" } });
208
+
209
+ const applyButton = screen.getByText("Apply Filters");
210
+ fireEvent.click(applyButton);
211
+
212
+ expect(defaultProps.onApplyFilters).toHaveBeenCalledWith(
213
+ expect.objectContaining({
214
+ ageFrom: "12"
215
+ })
216
+ );
217
+ });
218
+
219
+ it("displays correct filter values", () => {
220
+ const filtersWithValues = {
221
+ ...mockFilters,
222
+ status: "inactive",
223
+ selectedSiteId: "1",
224
+ dobFrom: "2020-01-01",
225
+ dobTo: "2020-12-31",
226
+ ageFrom: "12",
227
+ ageTo: "60",
228
+ };
229
+
230
+ render(
231
+ <ChildSearchFilters
232
+ {...defaultProps}
233
+ filters={filtersWithValues}
234
+ isAdvancedFiltersOpen={true}
235
+ />
236
+ );
237
+
238
+ const statusSelect = screen.getAllByTestId("select")[0];
239
+ const siteSelect = screen.getAllByTestId("select")[1];
240
+ const dateRangePicker = screen.getByTestId("date-range-picker");
241
+ const ageFromInput = screen.getAllByTestId("input")[0];
242
+ const ageToInput = screen.getAllByTestId("input")[1];
243
+
244
+ expect(statusSelect).toHaveValue("inactive");
245
+ expect(siteSelect).toHaveValue("1");
246
+ expect(dateRangePicker).toBeInTheDocument();
247
+ expect(ageFromInput).toHaveValue(12);
248
+ expect(ageToInput).toHaveValue(60);
249
+ });
250
+
251
+ it("has correct accessibility attributes", () => {
252
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
253
+
254
+ // Check that all inputs have proper labels
255
+ expect(screen.getByText("Status")).toBeInTheDocument();
256
+ expect(screen.getByText("Site")).toBeInTheDocument();
257
+ expect(screen.getByText("Date of Birth Range")).toBeInTheDocument();
258
+ expect(screen.getByText("Age From (months)")).toBeInTheDocument();
259
+ expect(screen.getByText("Age To (months)")).toBeInTheDocument();
260
+ });
261
+
262
+ it("applies correct CSS classes for dark mode support", () => {
263
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
264
+
265
+ const inputs = screen.getAllByTestId("input");
266
+ const selects = screen.getAllByTestId("select");
267
+
268
+ inputs.forEach(input => {
269
+ expect(input.className).toContain("dark:bg-gray-800");
270
+ expect(input.className).toContain("dark:border-gray-600");
271
+ expect(input.className).toContain("dark:text-white");
272
+ });
273
+
274
+ selects.forEach(select => {
275
+ expect(select.className).toContain("dark:bg-gray-800");
276
+ expect(select.className).toContain("dark:border-gray-600");
277
+ expect(select.className).toContain("dark:text-white");
278
+ });
279
+ });
280
+ });
@@ -0,0 +1,173 @@
1
+ import * as React from "react"
2
+ import {
3
+ ChevronDownIcon,
4
+ ChevronLeftIcon,
5
+ ChevronRightIcon,
6
+ } from "lucide-react"
7
+ import { DayPicker, getDefaultClassNames } from "react-day-picker";
8
+
9
+ import { cn } from "../../lib/utils"
10
+ import { Button, buttonVariants } from "./button"
11
+
12
+ function Calendar({
13
+ className,
14
+ classNames,
15
+ showOutsideDays = true,
16
+ captionLayout = "label",
17
+ buttonVariant = "ghost",
18
+ formatters,
19
+ components,
20
+ ...props
21
+ }) {
22
+ const defaultClassNames = getDefaultClassNames()
23
+
24
+ return (
25
+ <DayPicker
26
+ showOutsideDays={showOutsideDays}
27
+ className={cn(
28
+ "bg-background group/calendar p-3",
29
+ className
30
+ )}
31
+ captionLayout={captionLayout}
32
+ formatters={{
33
+ formatMonthDropdown: (date) =>
34
+ date.toLocaleString("default", { month: "short" }),
35
+ ...formatters,
36
+ }}
37
+ classNames={{
38
+ root: cn("w-fit", defaultClassNames.root),
39
+ months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
40
+ month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
41
+ nav: cn(
42
+ "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
43
+ defaultClassNames.nav
44
+ ),
45
+ button_previous: cn(
46
+ buttonVariants({ variant: buttonVariant }),
47
+ "h-8 w-8 aria-disabled:opacity-50 p-0 select-none",
48
+ defaultClassNames.button_previous
49
+ ),
50
+ button_next: cn(
51
+ buttonVariants({ variant: buttonVariant }),
52
+ "h-8 w-8 aria-disabled:opacity-50 p-0 select-none",
53
+ defaultClassNames.button_next
54
+ ),
55
+ month_caption: cn(
56
+ "flex items-center justify-center h-8 w-full px-8",
57
+ defaultClassNames.month_caption
58
+ ),
59
+ dropdowns: cn(
60
+ "w-full flex items-center text-sm font-medium justify-center h-8 gap-1.5",
61
+ defaultClassNames.dropdowns
62
+ ),
63
+ dropdown_root: cn(
64
+ "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
65
+ defaultClassNames.dropdown_root
66
+ ),
67
+ dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
68
+ caption_label: cn("select-none font-medium", captionLayout === "label"
69
+ ? "text-sm"
70
+ : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", defaultClassNames.caption_label),
71
+ table: "w-full border-collapse",
72
+ weekdays: cn("flex", defaultClassNames.weekdays),
73
+ weekday: cn(
74
+ "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
75
+ defaultClassNames.weekday
76
+ ),
77
+ week: cn("flex w-full mt-2", defaultClassNames.week),
78
+ week_number_header: cn("select-none w-8", defaultClassNames.week_number_header),
79
+ week_number: cn(
80
+ "text-[0.8rem] select-none text-muted-foreground",
81
+ defaultClassNames.week_number
82
+ ),
83
+ day: cn(
84
+ "relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
85
+ defaultClassNames.day
86
+ ),
87
+ range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
88
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
89
+ range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
90
+ today: cn(
91
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
92
+ defaultClassNames.today
93
+ ),
94
+ outside: cn(
95
+ "text-muted-foreground aria-selected:text-muted-foreground aria-selected:bg-muted aria-selected:opacity-50",
96
+ defaultClassNames.outside
97
+ ),
98
+ disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
99
+ hidden: cn("invisible", defaultClassNames.hidden),
100
+ ...classNames,
101
+ }}
102
+ components={{
103
+ Root: ({ className, rootRef, ...props }) => {
104
+ return (<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />);
105
+ },
106
+ Chevron: ({ className, orientation, ...props }) => {
107
+ if (orientation === "left") {
108
+ return (<ChevronLeftIcon className={cn("size-4", className)} {...props} />);
109
+ }
110
+
111
+ if (orientation === "right") {
112
+ return (<ChevronRightIcon className={cn("size-4", className)} {...props} />);
113
+ }
114
+
115
+ return (<ChevronDownIcon className={cn("size-4", className)} {...props} />);
116
+ },
117
+ DayButton: CalendarDayButton,
118
+ WeekNumber: ({ children, ...props }) => {
119
+ return (
120
+ <td {...props}>
121
+ <div
122
+ className="flex h-8 w-8 items-center justify-center text-center">
123
+ {children}
124
+ </div>
125
+ </td>
126
+ );
127
+ },
128
+ ...components,
129
+ }}
130
+ {...props} />
131
+ );
132
+ }
133
+
134
+ function CalendarDayButton({
135
+ className,
136
+ day,
137
+ modifiers,
138
+ ...props
139
+ }) {
140
+ const defaultClassNames = getDefaultClassNames()
141
+
142
+ const ref = React.useRef(null)
143
+ React.useEffect(() => {
144
+ if (modifiers.focused) ref.current?.focus()
145
+ }, [modifiers.focused])
146
+
147
+ return (
148
+ <Button
149
+ ref={ref}
150
+ variant="ghost"
151
+ size="icon"
152
+ data-day={day.date.toLocaleDateString()}
153
+ data-selected-single={
154
+ modifiers.selected &&
155
+ !modifiers.range_start &&
156
+ !modifiers.range_end &&
157
+ !modifiers.range_middle
158
+ }
159
+ data-range-start={modifiers.range_start}
160
+ data-range-end={modifiers.range_end}
161
+ data-range-middle={modifiers.range_middle}
162
+ className={cn(
163
+ "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-8 flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
164
+ // Add specific styling for outside selected dates
165
+ modifiers.outside && modifiers.selected && "bg-muted text-muted-foreground opacity-50",
166
+ defaultClassNames.day,
167
+ className
168
+ )}
169
+ {...props} />
170
+ );
171
+ }
172
+
173
+ export { Calendar, CalendarDayButton }