@mostrom/app-shell 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.
Files changed (142) hide show
  1. package/.claude/ralph-loop.local.md +9 -0
  2. package/README.md +172 -0
  3. package/bin/init.js +269 -0
  4. package/bun.lock +401 -0
  5. package/components.json +28 -0
  6. package/package.json +74 -0
  7. package/scripts/publish-npm.sh +202 -0
  8. package/src/AppShell.tsx +847 -0
  9. package/src/components/PageHeader.tsx +160 -0
  10. package/src/components/data-table/README.md +447 -0
  11. package/src/components/data-table/data-table-preferences.tsx +184 -0
  12. package/src/components/data-table/data-table-toolbar.tsx +118 -0
  13. package/src/components/data-table/data-table.tsx +37 -0
  14. package/src/components/data-table/index.ts +32 -0
  15. package/src/components/global-header/AllServicesButton.tsx +127 -0
  16. package/src/components/global-header/CategoriesButton.tsx +120 -0
  17. package/src/components/global-header/GlobalHeader.tsx +59 -0
  18. package/src/components/global-header/GlobalHeaderSearch.tsx +57 -0
  19. package/src/components/global-header/HeaderUtilities.tsx +243 -0
  20. package/src/components/global-header/ServicesMenu.tsx +246 -0
  21. package/src/components/layout/AppBreadcrumb.tsx +70 -0
  22. package/src/components/layout/AppFlashbar.tsx +95 -0
  23. package/src/components/layout/AppLayout.tsx +271 -0
  24. package/src/components/layout/AppNavigation.tsx +313 -0
  25. package/src/components/layout/AppSidebar.tsx +229 -0
  26. package/src/components/patterns/index.ts +14 -0
  27. package/src/components/patterns/p-alert-5.tsx +19 -0
  28. package/src/components/patterns/p-autocomplete-5.tsx +89 -0
  29. package/src/components/patterns/p-breadcrumb-1.tsx +28 -0
  30. package/src/components/patterns/p-button-42.tsx +37 -0
  31. package/src/components/patterns/p-button-51.tsx +14 -0
  32. package/src/components/patterns/p-button-6.tsx +5 -0
  33. package/src/components/patterns/p-calendar-1.tsx +18 -0
  34. package/src/components/patterns/p-card-1.tsx +33 -0
  35. package/src/components/patterns/p-card-2.tsx +26 -0
  36. package/src/components/patterns/p-card-5.tsx +31 -0
  37. package/src/components/patterns/p-collapsible-7.tsx +121 -0
  38. package/src/components/patterns/p-command-6.tsx +113 -0
  39. package/src/components/patterns/p-dialog-1.tsx +56 -0
  40. package/src/components/patterns/p-dropdown-menu-1.tsx +38 -0
  41. package/src/components/patterns/p-dropdown-menu-11.tsx +122 -0
  42. package/src/components/patterns/p-dropdown-menu-14.tsx +165 -0
  43. package/src/components/patterns/p-dropdown-menu-9.tsx +108 -0
  44. package/src/components/patterns/p-empty-2.tsx +34 -0
  45. package/src/components/patterns/p-file-upload-1.tsx +72 -0
  46. package/src/components/patterns/p-filters-1.tsx +666 -0
  47. package/src/components/patterns/p-frame-2.tsx +26 -0
  48. package/src/components/patterns/p-tabs-2.tsx +129 -0
  49. package/src/components/reui/alert.tsx +92 -0
  50. package/src/components/reui/autocomplete.tsx +343 -0
  51. package/src/components/reui/badge.tsx +87 -0
  52. package/src/components/reui/data-grid/data-grid-column-filter.tsx +165 -0
  53. package/src/components/reui/data-grid/data-grid-column-header.tsx +339 -0
  54. package/src/components/reui/data-grid/data-grid-column-visibility.tsx +55 -0
  55. package/src/components/reui/data-grid/data-grid-pagination.tsx +224 -0
  56. package/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx +260 -0
  57. package/src/components/reui/data-grid/data-grid-table-dnd.tsx +253 -0
  58. package/src/components/reui/data-grid/data-grid-table.tsx +639 -0
  59. package/src/components/reui/data-grid/data-grid.tsx +209 -0
  60. package/src/components/reui/date-selector.tsx +1330 -0
  61. package/src/components/reui/filters.tsx +1869 -0
  62. package/src/components/reui/frame.tsx +134 -0
  63. package/src/components/reui/index.ts +17 -0
  64. package/src/components/reui/timeline.tsx +219 -0
  65. package/src/components/search/Autocomplete.tsx +183 -0
  66. package/src/components/search/AutocompleteClient.tsx +293 -0
  67. package/src/components/search/GlobalSearch.tsx +187 -0
  68. package/src/components/section-drawer/deal-drawer-content.tsx +891 -0
  69. package/src/components/section-drawer/index.ts +19 -0
  70. package/src/components/section-drawer/section-drawer.css +665 -0
  71. package/src/components/section-drawer/section-drawer.tsx +467 -0
  72. package/src/components/sectioned-list-board/README.md +78 -0
  73. package/src/components/sectioned-list-board/board-card-content.tsx +340 -0
  74. package/src/components/sectioned-list-board/date-range-filter.tsx +249 -0
  75. package/src/components/sectioned-list-board/index.ts +19 -0
  76. package/src/components/sectioned-list-board/sectioned-list-board.css +564 -0
  77. package/src/components/sectioned-list-board/sectioned-list-board.tsx +731 -0
  78. package/src/components/sectioned-list-board/sortable-card.tsx +314 -0
  79. package/src/components/sectioned-list-board/sortable-section.tsx +319 -0
  80. package/src/components/sectioned-list-board/types.ts +216 -0
  81. package/src/components/sectioned-list-table/README.md +80 -0
  82. package/src/components/sectioned-list-table/index.ts +14 -0
  83. package/src/components/sectioned-list-table/sectioned-list-table.css +534 -0
  84. package/src/components/sectioned-list-table/sectioned-list-table.tsx +740 -0
  85. package/src/components/sectioned-list-table/sortable-column-header.tsx +120 -0
  86. package/src/components/sectioned-list-table/sortable-row.tsx +420 -0
  87. package/src/components/sectioned-list-table/sortable-section.tsx +251 -0
  88. package/src/components/sectioned-list-table/table-cell-content.tsx +129 -0
  89. package/src/components/sectioned-list-table/types.ts +120 -0
  90. package/src/components/sectioned-list-table/use-column-preferences.ts +103 -0
  91. package/src/components/ui/actions-dropdown.tsx +109 -0
  92. package/src/components/ui/assignee-selector.tsx +209 -0
  93. package/src/components/ui/avatar.tsx +107 -0
  94. package/src/components/ui/breadcrumb.tsx +109 -0
  95. package/src/components/ui/button-group.tsx +83 -0
  96. package/src/components/ui/button.tsx +64 -0
  97. package/src/components/ui/calendar.tsx +220 -0
  98. package/src/components/ui/card.tsx +92 -0
  99. package/src/components/ui/chart.tsx +376 -0
  100. package/src/components/ui/checkbox.tsx +30 -0
  101. package/src/components/ui/collapsible.tsx +33 -0
  102. package/src/components/ui/command.tsx +182 -0
  103. package/src/components/ui/context-menu.tsx +250 -0
  104. package/src/components/ui/create-button-group.tsx +128 -0
  105. package/src/components/ui/dialog.tsx +156 -0
  106. package/src/components/ui/drawer.tsx +133 -0
  107. package/src/components/ui/dropdown-menu.tsx +255 -0
  108. package/src/components/ui/empty.tsx +104 -0
  109. package/src/components/ui/field.tsx +248 -0
  110. package/src/components/ui/form.tsx +165 -0
  111. package/src/components/ui/index.ts +37 -0
  112. package/src/components/ui/input-group.tsx +168 -0
  113. package/src/components/ui/input.tsx +21 -0
  114. package/src/components/ui/kbd.tsx +28 -0
  115. package/src/components/ui/label.tsx +22 -0
  116. package/src/components/ui/navigation-menu.tsx +168 -0
  117. package/src/components/ui/page-header.tsx +80 -0
  118. package/src/components/ui/popover.tsx +87 -0
  119. package/src/components/ui/scroll-area.tsx +56 -0
  120. package/src/components/ui/select.tsx +190 -0
  121. package/src/components/ui/separator.tsx +26 -0
  122. package/src/components/ui/sheet.tsx +141 -0
  123. package/src/components/ui/sidebar.tsx +726 -0
  124. package/src/components/ui/skeleton.tsx +13 -0
  125. package/src/components/ui/sonner.tsx +38 -0
  126. package/src/components/ui/switch.tsx +33 -0
  127. package/src/components/ui/tabs.tsx +91 -0
  128. package/src/components/ui/textarea.tsx +18 -0
  129. package/src/components/ui/toggle-group.tsx +83 -0
  130. package/src/components/ui/toggle.tsx +45 -0
  131. package/src/components/ui/tooltip.tsx +57 -0
  132. package/src/hooks/use-copy-to-clipboard.ts +37 -0
  133. package/src/hooks/use-file-upload.ts +415 -0
  134. package/src/hooks/use-mobile.ts +19 -0
  135. package/src/index.ts +95 -0
  136. package/src/lib/utils.ts +6 -0
  137. package/src/styles.css +1859 -0
  138. package/src/urls.ts +83 -0
  139. package/src/vite.d.ts +22 -0
  140. package/src/vite.js +241 -0
  141. package/tsconfig.base.json +18 -0
  142. package/tsconfig.json +24 -0
@@ -0,0 +1,1330 @@
1
+ import {
2
+ ChangeEvent,
3
+ ComponentProps,
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useState,
10
+ } from "react"
11
+ import {
12
+ addMonths,
13
+ format,
14
+ isBefore,
15
+ isSameMonth,
16
+ parse,
17
+ subMonths,
18
+ } from "date-fns"
19
+ import { DayButton } from "react-day-picker"
20
+ import type { DateRange } from "react-day-picker"
21
+
22
+ import { useIsMobile } from "@/hooks/use-mobile"
23
+ import { cn } from "@/lib/utils"
24
+ import { Button } from "@/components/ui/button"
25
+ import { Calendar, CalendarDayButton } from "@/components/ui/calendar"
26
+ import { Input } from "@/components/ui/input"
27
+ import { ScrollArea } from "@/components/ui/scroll-area"
28
+ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
29
+ import { CornerUpLeftIcon, CornerUpRightIcon, ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"
30
+
31
+ export interface DateSelectorI18nConfig {
32
+ // Labels
33
+ selectDate: string
34
+ apply: string
35
+ cancel: string
36
+ clear: string
37
+ today: string
38
+ // Filter types
39
+ filterTypes: {
40
+ is: string
41
+ before: string
42
+ after: string
43
+ between: string
44
+ }
45
+ // Period types
46
+ periodTypes: {
47
+ day: string
48
+ month: string
49
+ quarter: string
50
+ halfYear: string
51
+ year: string
52
+ }
53
+ // Months
54
+ months: string[]
55
+ monthsShort: string[]
56
+ // Quarters
57
+ quarters: string[]
58
+ // Half years
59
+ halfYears: string[]
60
+ // Weekdays
61
+ weekdays: string[]
62
+ weekdaysShort: string[]
63
+ // Placeholders
64
+ placeholder: string
65
+ rangePlaceholder: string
66
+ }
67
+
68
+ export const DEFAULT_DATE_SELECTOR_I18N: DateSelectorI18nConfig = {
69
+ selectDate: "Select date",
70
+ apply: "Apply",
71
+ cancel: "Cancel",
72
+ clear: "Clear",
73
+ today: "Today",
74
+ filterTypes: {
75
+ is: "is",
76
+ before: "before",
77
+ after: "after",
78
+ between: "between",
79
+ },
80
+ periodTypes: {
81
+ day: "Day",
82
+ month: "Month",
83
+ quarter: "Quarter",
84
+ halfYear: "Half-year",
85
+ year: "Year",
86
+ },
87
+ months: [
88
+ "January",
89
+ "February",
90
+ "March",
91
+ "April",
92
+ "May",
93
+ "June",
94
+ "July",
95
+ "August",
96
+ "September",
97
+ "October",
98
+ "November",
99
+ "December",
100
+ ],
101
+ monthsShort: [
102
+ "Jan",
103
+ "Feb",
104
+ "Mar",
105
+ "Apr",
106
+ "May",
107
+ "Jun",
108
+ "Jul",
109
+ "Aug",
110
+ "Sep",
111
+ "Oct",
112
+ "Nov",
113
+ "Dec",
114
+ ],
115
+ quarters: ["Q1", "Q2", "Q3", "Q4"],
116
+ halfYears: ["H1", "H2"],
117
+ weekdays: [
118
+ "Sunday",
119
+ "Monday",
120
+ "Tuesday",
121
+ "Wednesday",
122
+ "Thursday",
123
+ "Friday",
124
+ "Saturday",
125
+ ],
126
+ weekdaysShort: ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"],
127
+ placeholder: "Select date...",
128
+ rangePlaceholder: "Select date range...",
129
+ }
130
+
131
+ export type DateSelectorPeriodType =
132
+ | "day"
133
+ | "month"
134
+ | "quarter"
135
+ | "half-year"
136
+ | "year"
137
+ export type DateSelectorFilterType = "is" | "before" | "after" | "between"
138
+
139
+ export interface DateSelectorValue {
140
+ period: DateSelectorPeriodType
141
+ operator: DateSelectorFilterType
142
+ startDate?: Date
143
+ endDate?: Date
144
+ year?: number
145
+ month?: number
146
+ quarter?: number
147
+ halfYear?: number
148
+ rangeStart?: { year: number; value: number }
149
+ rangeEnd?: { year: number; value: number }
150
+ }
151
+
152
+ export interface DateSelectorContextValue {
153
+ i18n: DateSelectorI18nConfig
154
+ variant: "outline" | "default"
155
+ size: "sm" | "default" | "lg"
156
+ }
157
+
158
+ const DateSelectorContext = createContext<DateSelectorContextValue>({
159
+ i18n: DEFAULT_DATE_SELECTOR_I18N,
160
+ variant: "outline",
161
+ size: "default",
162
+ })
163
+
164
+ export const useDateSelectorContext = () => useContext(DateSelectorContext)
165
+
166
+ export function formatDateValue(
167
+ value: DateSelectorValue,
168
+ i18n: DateSelectorI18nConfig = DEFAULT_DATE_SELECTOR_I18N,
169
+ dayDateFormat: string = "MM/dd/yyyy"
170
+ ): string {
171
+ const {
172
+ period,
173
+ startDate,
174
+ endDate,
175
+ year,
176
+ month,
177
+ quarter,
178
+ halfYear,
179
+ rangeStart,
180
+ rangeEnd,
181
+ } = value
182
+
183
+ if (period === "day") {
184
+ if (startDate && endDate) {
185
+ return `${format(startDate, dayDateFormat)} - ${format(endDate, dayDateFormat)}`
186
+ }
187
+ if (startDate) {
188
+ return format(startDate, dayDateFormat)
189
+ }
190
+ return ""
191
+ }
192
+
193
+ if (period === "month") {
194
+ if (rangeStart && rangeEnd) {
195
+ return `${i18n.monthsShort[rangeStart.value]} ${rangeStart.year} - ${i18n.monthsShort[rangeEnd.value]} ${rangeEnd.year}`
196
+ }
197
+ if (year !== undefined && month !== undefined) {
198
+ return `${i18n.monthsShort[month]} ${year}`
199
+ }
200
+ return ""
201
+ }
202
+
203
+ if (period === "quarter") {
204
+ if (rangeStart && rangeEnd) {
205
+ return `${i18n.quarters[rangeStart.value]} ${rangeStart.year} - ${i18n.quarters[rangeEnd.value]} ${rangeEnd.year}`
206
+ }
207
+ if (year !== undefined && quarter !== undefined) {
208
+ return `${i18n.quarters[quarter]} ${year}`
209
+ }
210
+ return ""
211
+ }
212
+
213
+ if (period === "half-year") {
214
+ if (rangeStart && rangeEnd) {
215
+ return `${i18n.halfYears[rangeStart.value]} ${rangeStart.year} - ${i18n.halfYears[rangeEnd.value]} ${rangeEnd.year}`
216
+ }
217
+ if (year !== undefined && halfYear !== undefined) {
218
+ return `${i18n.halfYears[halfYear]} ${year}`
219
+ }
220
+ return ""
221
+ }
222
+
223
+ if (period === "year") {
224
+ if (rangeStart && rangeEnd) {
225
+ return `${rangeStart.year} - ${rangeEnd.year}`
226
+ }
227
+ if (year !== undefined) {
228
+ return `${year}`
229
+ }
230
+ return ""
231
+ }
232
+
233
+ return ""
234
+ }
235
+
236
+ interface UseDateSelectorOptions {
237
+ value?: DateSelectorValue
238
+ onChange?: (value: DateSelectorValue) => void
239
+ defaultPeriodType?: DateSelectorPeriodType
240
+ defaultFilterType?: DateSelectorFilterType
241
+ presetMode?: DateSelectorFilterType
242
+ allowRange?: boolean
243
+ yearRange?: number
244
+ baseYear?: number
245
+ minYear?: number
246
+ maxYear?: number
247
+ periodTypes?: DateSelectorPeriodType[]
248
+ }
249
+
250
+ export function useDateSelector({
251
+ value,
252
+ onChange,
253
+ defaultPeriodType = "day",
254
+ defaultFilterType = "is",
255
+ presetMode,
256
+ allowRange = true,
257
+ yearRange = 11,
258
+ baseYear,
259
+ minYear,
260
+ maxYear,
261
+ periodTypes,
262
+ }: UseDateSelectorOptions) {
263
+ const currentYear = baseYear ?? new Date().getFullYear()
264
+
265
+ const validDefaultPeriodType = useMemo(() => {
266
+ if (!periodTypes || periodTypes.length === 0) return defaultPeriodType
267
+ if (periodTypes.includes(defaultPeriodType)) return defaultPeriodType
268
+ return periodTypes[0]
269
+ }, [periodTypes, defaultPeriodType])
270
+
271
+ // Use presetMode if provided, otherwise use value or default
272
+ const effectiveFilterType = presetMode ?? value?.operator ?? defaultFilterType
273
+
274
+ const [periodType, setPeriodType] = useState<DateSelectorPeriodType>(
275
+ value?.period || validDefaultPeriodType
276
+ )
277
+ const [filterType, setFilterType] =
278
+ useState<DateSelectorFilterType>(effectiveFilterType)
279
+ const [selectedDate, setSelectedDate] = useState<Date | undefined>(
280
+ value?.startDate
281
+ )
282
+ const [selectedEndDate, setSelectedEndDate] = useState<Date | undefined>(
283
+ value?.endDate
284
+ )
285
+ const [calendarMonth, setCalendarMonth] = useState(
286
+ value?.startDate || new Date()
287
+ )
288
+ const [selectedYear, setSelectedYear] = useState<number | undefined>(
289
+ value?.year
290
+ )
291
+ const [selectedMonth, setSelectedMonth] = useState<number | undefined>(
292
+ value?.month
293
+ )
294
+ const [selectedQuarter, setSelectedQuarter] = useState<number | undefined>(
295
+ value?.quarter
296
+ )
297
+ const [selectedHalfYear, setSelectedHalfYear] = useState<number | undefined>(
298
+ value?.halfYear
299
+ )
300
+ const [rangeStart, setRangeStart] = useState<
301
+ { year: number; value: number } | undefined
302
+ >(value?.rangeStart)
303
+ const [rangeEnd, setRangeEnd] = useState<
304
+ { year: number; value: number } | undefined
305
+ >(value?.rangeEnd)
306
+ const [hoverDate, setHoverDate] = useState<Date | undefined>()
307
+
308
+ const years = useMemo(() => {
309
+ if (minYear !== undefined && maxYear !== undefined) {
310
+ return Array.from(
311
+ { length: maxYear - minYear + 1 },
312
+ (_, i) => minYear + i
313
+ )
314
+ }
315
+ return Array.from(
316
+ { length: yearRange },
317
+ (_, i) => currentYear - Math.floor(yearRange / 2) + i
318
+ )
319
+ }, [currentYear, yearRange, minYear, maxYear])
320
+
321
+ const currentValue = useMemo<DateSelectorValue>(
322
+ () => ({
323
+ period: periodType,
324
+ operator: presetMode ?? filterType,
325
+ startDate: selectedDate,
326
+ endDate: selectedEndDate,
327
+ year: selectedYear,
328
+ month: selectedMonth,
329
+ quarter: selectedQuarter,
330
+ halfYear: selectedHalfYear,
331
+ rangeStart,
332
+ rangeEnd,
333
+ }),
334
+ [
335
+ periodType,
336
+ presetMode,
337
+ filterType,
338
+ selectedDate,
339
+ selectedEndDate,
340
+ selectedYear,
341
+ selectedMonth,
342
+ selectedQuarter,
343
+ selectedHalfYear,
344
+ rangeStart,
345
+ rangeEnd,
346
+ ]
347
+ )
348
+
349
+ const clearSelection = useCallback(() => {
350
+ setSelectedDate(undefined)
351
+ setSelectedEndDate(undefined)
352
+ setSelectedYear(undefined)
353
+ setSelectedMonth(undefined)
354
+ setSelectedQuarter(undefined)
355
+ setSelectedHalfYear(undefined)
356
+ setRangeStart(undefined)
357
+ setRangeEnd(undefined)
358
+ }, [])
359
+
360
+ const handleDayClick = useCallback(
361
+ (day: Date) => {
362
+ if (filterType === "between" && allowRange) {
363
+ if (!selectedDate || (selectedDate && selectedEndDate)) {
364
+ setSelectedDate(day)
365
+ setSelectedEndDate(undefined)
366
+ } else {
367
+ if (isBefore(day, selectedDate)) {
368
+ setSelectedEndDate(selectedDate)
369
+ setSelectedDate(day)
370
+ } else {
371
+ setSelectedEndDate(day)
372
+ }
373
+ }
374
+ } else {
375
+ setSelectedDate(day)
376
+ setSelectedEndDate(undefined)
377
+ }
378
+ },
379
+ [filterType, allowRange, selectedDate, selectedEndDate]
380
+ )
381
+
382
+ const handlePeriodSelect = useCallback(
383
+ (year: number, value: number) => {
384
+ if (filterType === "between" && allowRange) {
385
+ if (!rangeStart || (rangeStart && rangeEnd)) {
386
+ setRangeStart({ year, value })
387
+ setRangeEnd(undefined)
388
+ setSelectedYear(year)
389
+ if (periodType === "month") setSelectedMonth(value)
390
+ if (periodType === "quarter") setSelectedQuarter(value)
391
+ if (periodType === "half-year") setSelectedHalfYear(value)
392
+ } else {
393
+ const startKey = rangeStart.year * 100 + rangeStart.value
394
+ const endKey = year * 100 + value
395
+ if (endKey < startKey) {
396
+ setRangeEnd(rangeStart)
397
+ setRangeStart({ year, value })
398
+ } else {
399
+ setRangeEnd({ year, value })
400
+ }
401
+ }
402
+ } else {
403
+ setSelectedYear(year)
404
+ if (periodType === "month") setSelectedMonth(value)
405
+ if (periodType === "quarter") setSelectedQuarter(value)
406
+ if (periodType === "half-year") setSelectedHalfYear(value)
407
+ setRangeStart(undefined)
408
+ setRangeEnd(undefined)
409
+ }
410
+ },
411
+ [filterType, allowRange, rangeStart, rangeEnd, periodType]
412
+ )
413
+
414
+ const handleYearSelect = useCallback(
415
+ (year: number) => {
416
+ if (filterType === "between" && allowRange) {
417
+ if (!rangeStart || (rangeStart && rangeEnd)) {
418
+ setRangeStart({ year, value: 0 })
419
+ setRangeEnd(undefined)
420
+ setSelectedYear(year)
421
+ } else {
422
+ if (year < rangeStart.year) {
423
+ setRangeEnd(rangeStart)
424
+ setRangeStart({ year, value: 0 })
425
+ } else {
426
+ setRangeEnd({ year, value: 0 })
427
+ }
428
+ }
429
+ } else {
430
+ setSelectedYear(year)
431
+ setRangeStart(undefined)
432
+ setRangeEnd(undefined)
433
+ }
434
+ },
435
+ [filterType, allowRange, rangeStart, rangeEnd]
436
+ )
437
+
438
+ const handlePeriodTypeChange = useCallback(
439
+ (type: DateSelectorPeriodType) => {
440
+ setPeriodType(type)
441
+ clearSelection()
442
+ },
443
+ [clearSelection]
444
+ )
445
+
446
+ const handleFilterTypeChange = useCallback(
447
+ (type: DateSelectorFilterType) => {
448
+ // Don't allow changes if presetMode is set
449
+ if (presetMode !== undefined) return
450
+ setFilterType(type)
451
+ clearSelection()
452
+ },
453
+ [clearSelection, presetMode]
454
+ )
455
+
456
+ const isInRange = useCallback(
457
+ (year: number, value: number) => {
458
+ if (!rangeStart || !rangeEnd) return false
459
+ const key = year * 100 + value
460
+ const startKey = rangeStart.year * 100 + rangeStart.value
461
+ const endKey = rangeEnd.year * 100 + rangeEnd.value
462
+ return key >= startKey && key <= endKey
463
+ },
464
+ [rangeStart, rangeEnd]
465
+ )
466
+
467
+ const isYearInRange = useCallback(
468
+ (year: number) => {
469
+ if (!rangeStart || !rangeEnd) return false
470
+ return year >= rangeStart.year && year <= rangeEnd.year
471
+ },
472
+ [rangeStart, rangeEnd]
473
+ )
474
+
475
+ useEffect(() => {
476
+ if (value) {
477
+ setPeriodType(value.period || validDefaultPeriodType)
478
+ // Use presetMode if provided, otherwise use value's operator or default
479
+ const newFilterType = presetMode ?? value.operator ?? defaultFilterType
480
+ setFilterType(newFilterType)
481
+ setSelectedDate(value.startDate)
482
+ setSelectedEndDate(value.endDate)
483
+ setSelectedYear(value.year)
484
+ setSelectedMonth(value.month)
485
+ setSelectedQuarter(value.quarter)
486
+ setSelectedHalfYear(value.halfYear)
487
+ setRangeStart(value.rangeStart)
488
+ setRangeEnd(value.rangeEnd)
489
+ }
490
+ }, [value, validDefaultPeriodType, defaultFilterType, presetMode])
491
+
492
+ // Sync filterType when presetMode changes
493
+ useEffect(() => {
494
+ if (presetMode !== undefined) {
495
+ setFilterType(presetMode)
496
+ }
497
+ }, [presetMode])
498
+
499
+ useEffect(() => {
500
+ onChange?.(currentValue)
501
+ }, [currentValue, onChange])
502
+
503
+ return {
504
+ // State
505
+ periodType,
506
+ filterType,
507
+ selectedDate,
508
+ selectedEndDate,
509
+ calendarMonth,
510
+ selectedYear,
511
+ selectedMonth,
512
+ selectedQuarter,
513
+ selectedHalfYear,
514
+ rangeStart,
515
+ rangeEnd,
516
+ hoverDate,
517
+ years,
518
+ currentValue,
519
+ allowRange,
520
+
521
+ // Setters
522
+ setPeriodType: handlePeriodTypeChange,
523
+ setFilterType: handleFilterTypeChange,
524
+ setSelectedDate,
525
+ setSelectedEndDate,
526
+ setCalendarMonth,
527
+ setHoverDate,
528
+
529
+ // Actions
530
+ clearSelection,
531
+ handleDayClick,
532
+ handlePeriodSelect,
533
+ handleYearSelect,
534
+ isInRange,
535
+ isYearInRange,
536
+ }
537
+ }
538
+
539
+ interface DateSelectorFilterToggleProps {
540
+ value: DateSelectorFilterType
541
+ onChange: (value: DateSelectorFilterType) => void
542
+ showBetween?: boolean
543
+ showIs?: boolean
544
+ presetMode?: DateSelectorFilterType
545
+ className?: string
546
+ }
547
+
548
+ function DateSelectorFilterToggle({
549
+ value,
550
+ onChange,
551
+ showBetween = true,
552
+ showIs = true,
553
+ presetMode,
554
+ className,
555
+ }: DateSelectorFilterToggleProps) {
556
+ const { i18n } = useDateSelectorContext()
557
+ const isDisabled = presetMode !== undefined
558
+
559
+ return (
560
+ <Tabs
561
+ value={value}
562
+ onValueChange={(newValue) => {
563
+ if (!isDisabled && newValue) {
564
+ onChange(newValue as DateSelectorFilterType)
565
+ }
566
+ }}
567
+ className={className}
568
+ >
569
+ <TabsList
570
+ className={cn(
571
+ "bg-muted/80",
572
+ isDisabled && "pointer-events-none opacity-50",
573
+ className
574
+ )}
575
+ >
576
+ {showIs && (
577
+ <TabsTrigger
578
+ value="is"
579
+ aria-label={i18n.filterTypes.is}
580
+ className="py-1 font-normal"
581
+ >
582
+ {i18n.filterTypes.is}
583
+ </TabsTrigger>
584
+ )}
585
+ <TabsTrigger
586
+ value="before"
587
+ aria-label={i18n.filterTypes.before}
588
+ className="py-1 font-normal"
589
+ >
590
+ {i18n.filterTypes.before}
591
+ </TabsTrigger>
592
+ <TabsTrigger
593
+ value="after"
594
+ aria-label={i18n.filterTypes.after}
595
+ className="py-1 font-normal"
596
+ >
597
+ {i18n.filterTypes.after}
598
+ </TabsTrigger>
599
+ {showBetween && (
600
+ <TabsTrigger
601
+ value="between"
602
+ aria-label={i18n.filterTypes.between}
603
+ className="py-1 font-normal"
604
+ >
605
+ {i18n.filterTypes.between}
606
+ </TabsTrigger>
607
+ )}
608
+ </TabsList>
609
+ </Tabs>
610
+ )
611
+ }
612
+
613
+ interface DateSelectorDateSelectorPeriodTabsProps {
614
+ value: DateSelectorPeriodType
615
+ onChange: (value: DateSelectorPeriodType) => void
616
+ periodTypes?: DateSelectorPeriodType[]
617
+ className?: string
618
+ calendarMonth?: Date
619
+ onMonthChange?: (date: Date) => void
620
+ showNavigationButtons?: boolean
621
+ }
622
+
623
+ function DateSelectorPeriodTabs({
624
+ value,
625
+ onChange,
626
+ periodTypes,
627
+ className,
628
+ calendarMonth,
629
+ onMonthChange,
630
+ showNavigationButtons = false,
631
+ }: DateSelectorDateSelectorPeriodTabsProps) {
632
+ const { i18n } = useDateSelectorContext()
633
+
634
+ const tabs: { value: DateSelectorPeriodType; label: string }[] = [
635
+ { value: "day", label: i18n.periodTypes.day },
636
+ { value: "month", label: i18n.periodTypes.month },
637
+ { value: "quarter", label: i18n.periodTypes.quarter },
638
+ { value: "half-year", label: i18n.periodTypes.halfYear },
639
+ { value: "year", label: i18n.periodTypes.year },
640
+ ]
641
+
642
+ const filteredTabs = periodTypes
643
+ ? tabs.filter((tab) => periodTypes.includes(tab.value))
644
+ : tabs
645
+
646
+ return (
647
+ <div
648
+ className={cn(
649
+ "flex flex-wrap items-center justify-between gap-3",
650
+ className
651
+ )}
652
+ >
653
+ <Tabs
654
+ value={value}
655
+ onValueChange={(newValue) => {
656
+ if (newValue) {
657
+ onChange(newValue as DateSelectorPeriodType)
658
+ }
659
+ }}
660
+ >
661
+ <TabsList>
662
+ {filteredTabs.map((tab) => (
663
+ <TabsTrigger
664
+ key={tab.value}
665
+ value={tab.value}
666
+ aria-label={tab.label}
667
+ className="px-1 py-1 font-normal sm:px-2.5"
668
+ >
669
+ {tab.label}
670
+ </TabsTrigger>
671
+ ))}
672
+ </TabsList>
673
+ </Tabs>
674
+ {showNavigationButtons &&
675
+ value === "day" &&
676
+ calendarMonth &&
677
+ onMonthChange && (
678
+ <div className="flex items-center">
679
+ {(() => {
680
+ const today = new Date()
681
+ const isCurrentMonth = isSameMonth(calendarMonth, today)
682
+
683
+ // Only show today button if not on current month
684
+ if (isCurrentMonth) {
685
+ return null
686
+ }
687
+
688
+ // Determine direction based on whether calendarMonth is in future or past
689
+ const isFuture = calendarMonth > today
690
+
691
+ return (
692
+ <Button
693
+ variant="ghost"
694
+ className="size-8.5"
695
+ onClick={() => onMonthChange(new Date())}
696
+ title={i18n.today}
697
+ >
698
+ {isFuture ? (
699
+ <CornerUpLeftIcon
700
+ />
701
+ ) : (
702
+ <CornerUpRightIcon
703
+ />
704
+ )}
705
+ </Button>
706
+ )
707
+ })()}
708
+ <Button
709
+ variant="ghost"
710
+ className="size-8.5"
711
+ onClick={() => onMonthChange(subMonths(calendarMonth, 1))}
712
+ >
713
+ <ChevronLeftIcon className="size-4" />
714
+ </Button>
715
+ <Button
716
+ variant="ghost"
717
+ className="size-8.5"
718
+ onClick={() => onMonthChange(addMonths(calendarMonth, 1))}
719
+ >
720
+ <ChevronRightIcon className="size-4" />
721
+ </Button>
722
+ </div>
723
+ )}
724
+ </div>
725
+ )
726
+ }
727
+
728
+ interface DateSelectorDayPickerProps {
729
+ currentMonth: Date
730
+ selectedDate?: Date
731
+ selectedEndDate?: Date
732
+ onDayClick: (day: Date) => void
733
+ isRange: boolean
734
+ onDayHover?: (day: Date | undefined) => void
735
+ hoverDate?: Date
736
+ showTwoMonths?: boolean
737
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6
738
+ className?: string
739
+ }
740
+
741
+ function DateSelectorDayPicker({
742
+ currentMonth,
743
+ selectedDate,
744
+ selectedEndDate,
745
+ onDayClick,
746
+ isRange,
747
+ onDayHover,
748
+ hoverDate,
749
+ showTwoMonths = true,
750
+ weekStartsOn,
751
+ className,
752
+ }: DateSelectorDayPickerProps) {
753
+ const { i18n } = useDateSelectorContext()
754
+ const isMobile = useIsMobile()
755
+
756
+ // Convert to react-day-picker format
757
+ const selected: Date | DateRange | undefined = isRange
758
+ ? selectedDate && selectedEndDate
759
+ ? { from: selectedDate, to: selectedEndDate }
760
+ : selectedDate
761
+ ? { from: selectedDate, to: hoverDate || selectedDate }
762
+ : undefined
763
+ : selectedDate
764
+
765
+ const handleSelect = (date: Date | DateRange | undefined) => {
766
+ if (!date) {
767
+ return
768
+ }
769
+
770
+ if (isRange && "from" in date) {
771
+ // For range mode
772
+ if (date.from && !date.to) {
773
+ // First click - set start date
774
+ onDayClick(date.from)
775
+ } else if (date.from && date.to) {
776
+ // Range selected - set end date
777
+ onDayClick(date.to)
778
+ }
779
+ } else if (!isRange && date instanceof Date) {
780
+ onDayClick(date)
781
+ }
782
+ }
783
+
784
+ // Create custom DayButton component with hover support
785
+ const CustomDayButton = useCallback(
786
+ (props: ComponentProps<typeof DayButton>) => {
787
+ return (
788
+ <CalendarDayButton
789
+ {...props}
790
+ onMouseEnter={() => {
791
+ if (isRange && onDayHover && props.day) {
792
+ onDayHover(props.day.date)
793
+ }
794
+ }}
795
+ onMouseLeave={() => {
796
+ if (isRange && onDayHover) {
797
+ onDayHover(undefined)
798
+ }
799
+ }}
800
+ />
801
+ )
802
+ },
803
+ [isRange, onDayHover]
804
+ )
805
+
806
+ // Create custom formatters for i18n
807
+ const formatters = {
808
+ formatWeekdayName: (date: Date) => {
809
+ const dayIndex = date.getDay()
810
+ return i18n.weekdaysShort[dayIndex] || i18n.weekdays[dayIndex]
811
+ },
812
+ formatMonthCaption: (date: Date) => {
813
+ const monthIndex = date.getMonth()
814
+ const year = date.getFullYear()
815
+ return `${i18n.months[monthIndex]} ${year}`
816
+ },
817
+ }
818
+
819
+ return (
820
+ <div className={cn("flex w-full items-center justify-between", className)}>
821
+ {isRange ? (
822
+ <Calendar
823
+ month={currentMonth}
824
+ mode="range"
825
+ selected={selected as DateRange | undefined}
826
+ onSelect={handleSelect as (range: DateRange | undefined) => void}
827
+ numberOfMonths={isMobile ? 1 : showTwoMonths ? 2 : 1}
828
+ showOutsideDays={true}
829
+ weekStartsOn={weekStartsOn}
830
+ formatters={formatters}
831
+ className="w-full shrink-0 p-0"
832
+ classNames={{
833
+ months: "flex flex-wrap items-start justify-between gap-5 w-full",
834
+ month: "flex flex-col items-center min-w-0 flex-1",
835
+ nav: "hidden",
836
+ }}
837
+ components={{
838
+ DayButton: CustomDayButton,
839
+ }}
840
+ />
841
+ ) : (
842
+ <Calendar
843
+ month={currentMonth}
844
+ mode="single"
845
+ selected={selected as Date | undefined}
846
+ onSelect={handleSelect as (date: Date | undefined) => void}
847
+ numberOfMonths={isMobile ? 1 : showTwoMonths ? 2 : 1}
848
+ showOutsideDays={true}
849
+ weekStartsOn={weekStartsOn}
850
+ formatters={formatters}
851
+ className="w-full shrink-0 p-0"
852
+ classNames={{
853
+ months: "flex flex-wrap items-start justify-between gap-5 w-full",
854
+ month: "flex flex-col items-center min-w-0 flex-1",
855
+ nav: "hidden",
856
+ }}
857
+ components={{
858
+ DayButton: CustomDayButton,
859
+ }}
860
+ />
861
+ )}
862
+ </div>
863
+ )
864
+ }
865
+
866
+ interface DateSelectorDateSelectorPeriodGridProps {
867
+ years: number[]
868
+ items: string[]
869
+ selectedYear?: number
870
+ selectedValue?: number
871
+ rangeStart?: { year: number; value: number }
872
+ rangeEnd?: { year: number; value: number }
873
+ isInRange: (year: number, value: number) => boolean
874
+ onSelect: (year: number, value: number) => void
875
+ columns: number
876
+ className?: string
877
+ }
878
+
879
+ function DateSelectorPeriodGrid({
880
+ years,
881
+ items,
882
+ selectedYear,
883
+ selectedValue,
884
+ rangeStart,
885
+ rangeEnd,
886
+ isInRange,
887
+ onSelect,
888
+ columns,
889
+ className,
890
+ }: DateSelectorDateSelectorPeriodGridProps) {
891
+ return (
892
+ <div className={cn("w-full space-y-6", className)}>
893
+ {years.map((year) => (
894
+ <div key={year}>
895
+ <div className="text-muted-foreground mb-3 text-sm font-medium">
896
+ {year}
897
+ </div>
898
+ <div
899
+ className="grid gap-2"
900
+ style={{
901
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
902
+ }}
903
+ >
904
+ {items.map((item, index) => {
905
+ const isSelected =
906
+ selectedYear === year && selectedValue === index
907
+ const isRangeStart =
908
+ rangeStart?.year === year && rangeStart?.value === index
909
+ const isRangeEnd =
910
+ rangeEnd?.year === year && rangeEnd?.value === index
911
+ const inRange = isInRange(year, index)
912
+
913
+ return (
914
+ <Button
915
+ key={item}
916
+ size="sm"
917
+ variant={
918
+ isSelected || isRangeStart || isRangeEnd
919
+ ? "default"
920
+ : "outline"
921
+ }
922
+ className={cn(
923
+ inRange &&
924
+ !isSelected &&
925
+ !isRangeStart &&
926
+ !isRangeEnd &&
927
+ "bg-accent dark:bg-accent/60"
928
+ )}
929
+ onClick={() => onSelect(year, index)}
930
+ >
931
+ {item}
932
+ </Button>
933
+ )
934
+ })}
935
+ </div>
936
+ </div>
937
+ ))}
938
+ </div>
939
+ )
940
+ }
941
+
942
+ interface DateSelectorYearListProps {
943
+ years: number[]
944
+ selectedYear?: number
945
+ rangeStart?: { year: number; value: number }
946
+ rangeEnd?: { year: number; value: number }
947
+ isYearInRange: (year: number) => boolean
948
+ onSelect: (year: number) => void
949
+ className?: string
950
+ }
951
+
952
+ function DateSelectorYearList({
953
+ years,
954
+ selectedYear,
955
+ rangeStart,
956
+ rangeEnd,
957
+ isYearInRange,
958
+ onSelect,
959
+ className,
960
+ }: DateSelectorYearListProps) {
961
+ return (
962
+ <div className={cn("grid grid-cols-2 gap-2", className)}>
963
+ {years.map((year) => {
964
+ const isSelected = selectedYear === year && !rangeStart && !rangeEnd
965
+ const isRangeStart = rangeStart?.year === year
966
+ const isRangeEnd = rangeEnd?.year === year
967
+ const inRange = isYearInRange(year)
968
+
969
+ return (
970
+ <Button
971
+ key={year}
972
+ size="sm"
973
+ variant={
974
+ isSelected || isRangeStart || isRangeEnd ? "default" : "outline"
975
+ }
976
+ className={cn(
977
+ inRange &&
978
+ !isSelected &&
979
+ !isRangeStart &&
980
+ !isRangeEnd &&
981
+ "bg-accent dark:bg-accent/60"
982
+ )}
983
+ onClick={() => onSelect(year)}
984
+ >
985
+ {year}
986
+ </Button>
987
+ )
988
+ })}
989
+ </div>
990
+ )
991
+ }
992
+
993
+ export interface DateSelectorProps {
994
+ value?: DateSelectorValue
995
+ onChange?: (value: DateSelectorValue) => void
996
+ allowRange?: boolean
997
+ periodTypes?: DateSelectorPeriodType[]
998
+ defaultPeriodType?: DateSelectorPeriodType
999
+ defaultFilterType?: DateSelectorFilterType
1000
+ presetMode?: DateSelectorFilterType
1001
+ showInput?: boolean
1002
+ showTwoMonths?: boolean
1003
+ label?: string
1004
+ className?: string
1005
+ yearRange?: number
1006
+ baseYear?: number
1007
+ minYear?: number
1008
+ maxYear?: number
1009
+ i18n?: Partial<DateSelectorI18nConfig>
1010
+ inputHint?: string
1011
+ dayDateFormat?: string
1012
+ dayDateFormats?: string[]
1013
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6
1014
+ }
1015
+
1016
+ export function DateSelector({
1017
+ value,
1018
+ onChange,
1019
+ allowRange = true,
1020
+ periodTypes,
1021
+ defaultPeriodType = "day",
1022
+ defaultFilterType = "is",
1023
+ presetMode,
1024
+ showInput = true,
1025
+ showTwoMonths = true,
1026
+ label,
1027
+ className,
1028
+ yearRange = 10,
1029
+ baseYear,
1030
+ minYear = 2015,
1031
+ maxYear = 2026,
1032
+ i18n: i18nOverride,
1033
+ inputHint,
1034
+ dayDateFormat = "MM/dd/yyyy",
1035
+ dayDateFormats,
1036
+ weekStartsOn,
1037
+ }: DateSelectorProps) {
1038
+ const mergedI18n = useMemo(
1039
+ () => ({ ...DEFAULT_DATE_SELECTOR_I18N, ...i18nOverride }),
1040
+ [i18nOverride]
1041
+ )
1042
+
1043
+ const selector = useDateSelector({
1044
+ value,
1045
+ onChange,
1046
+ defaultPeriodType,
1047
+ defaultFilterType,
1048
+ presetMode,
1049
+ allowRange,
1050
+ yearRange,
1051
+ baseYear,
1052
+ minYear,
1053
+ maxYear,
1054
+ periodTypes,
1055
+ })
1056
+
1057
+ const {
1058
+ periodType,
1059
+ filterType,
1060
+ selectedDate,
1061
+ selectedEndDate,
1062
+ calendarMonth,
1063
+ selectedYear,
1064
+ selectedMonth,
1065
+ selectedQuarter,
1066
+ selectedHalfYear,
1067
+ rangeStart,
1068
+ rangeEnd,
1069
+ hoverDate,
1070
+ years,
1071
+ currentValue,
1072
+ setPeriodType,
1073
+ setFilterType,
1074
+ setCalendarMonth,
1075
+ setHoverDate,
1076
+ clearSelection,
1077
+ handleDayClick,
1078
+ handlePeriodSelect,
1079
+ handleYearSelect,
1080
+ isInRange,
1081
+ isYearInRange,
1082
+ } = selector
1083
+
1084
+ const displayValue = formatDateValue(currentValue, mergedI18n, dayDateFormat)
1085
+ const [inputValue, setInputValue] = useState(displayValue)
1086
+ const [isInputFocused, setIsInputFocused] = useState(false)
1087
+
1088
+ // Sync input value when displayValue changes (but not when user is typing)
1089
+ useEffect(() => {
1090
+ if (!isInputFocused) {
1091
+ setInputValue(displayValue)
1092
+ }
1093
+ }, [displayValue, isInputFocused])
1094
+
1095
+ // Compute date formats for parsing
1096
+ const dateFormats = useMemo(() => {
1097
+ if (dayDateFormats && dayDateFormats.length > 0) {
1098
+ // Use provided formats, with dayDateFormat first if not already included
1099
+ const formats = [...dayDateFormats]
1100
+ if (!formats.includes(dayDateFormat)) {
1101
+ formats.unshift(dayDateFormat)
1102
+ }
1103
+ return formats
1104
+ }
1105
+ // Default formats: use dayDateFormat first, then common alternatives
1106
+ const defaultFormats = [
1107
+ dayDateFormat,
1108
+ "dd/MM/yyyy",
1109
+ "yyyy-MM-dd",
1110
+ "MM-dd-yyyy",
1111
+ "dd-MM-yyyy",
1112
+ ]
1113
+ // Remove duplicates while preserving order
1114
+ return Array.from(new Set(defaultFormats))
1115
+ }, [dayDateFormat, dayDateFormats])
1116
+
1117
+ // Parse input text to DateSelectorValue
1118
+ const parseInputValue = useCallback(
1119
+ (text: string): DateSelectorValue | null => {
1120
+ if (!text.trim()) return null
1121
+
1122
+ const trimmed = text.trim()
1123
+
1124
+ // Try parsing as year (e.g., "2025")
1125
+ const yearMatch = trimmed.match(/^\d{4}$/)
1126
+ if (yearMatch) {
1127
+ const year = parseInt(yearMatch[0])
1128
+ if (year >= 1900 && year <= 2100) {
1129
+ return {
1130
+ period: "year",
1131
+ operator: presetMode ?? filterType,
1132
+ year,
1133
+ }
1134
+ }
1135
+ }
1136
+
1137
+ // Try parsing as quarter (e.g., "Q4", "Q1 2025")
1138
+ const quarterMatch = trimmed.match(/^Q([1-4])(?:\s+(\d{4}))?$/i)
1139
+ if (quarterMatch) {
1140
+ const quarter = parseInt(quarterMatch[1]) - 1
1141
+ const year = quarterMatch[2]
1142
+ ? parseInt(quarterMatch[2])
1143
+ : new Date().getFullYear()
1144
+ if (year >= 1900 && year <= 2100) {
1145
+ return {
1146
+ period: "quarter",
1147
+ operator: presetMode ?? filterType,
1148
+ year,
1149
+ quarter,
1150
+ }
1151
+ }
1152
+ }
1153
+
1154
+ // Try parsing as date using computed formats
1155
+ for (const dateFormat of dateFormats) {
1156
+ try {
1157
+ const parsed = parse(trimmed, dateFormat, new Date())
1158
+ if (!isNaN(parsed.getTime())) {
1159
+ return {
1160
+ period: "day",
1161
+ operator: presetMode ?? filterType,
1162
+ startDate: parsed,
1163
+ }
1164
+ }
1165
+ } catch {
1166
+ // Continue to next format
1167
+ }
1168
+ }
1169
+
1170
+ return null
1171
+ },
1172
+ [filterType, presetMode, dateFormats]
1173
+ )
1174
+
1175
+ const handleInputChange = useCallback(
1176
+ (e: ChangeEvent<HTMLInputElement>) => {
1177
+ const newValue = e.target.value
1178
+ setInputValue(newValue)
1179
+
1180
+ // Try to parse the input
1181
+ const parsed = parseInputValue(newValue)
1182
+ if (parsed) {
1183
+ onChange?.(parsed)
1184
+ }
1185
+ },
1186
+ [onChange, parseInputValue]
1187
+ )
1188
+
1189
+ const handleInputBlur = useCallback(() => {
1190
+ setIsInputFocused(false)
1191
+ // Reset to display value if parsing failed
1192
+ if (!parseInputValue(inputValue)) {
1193
+ setInputValue(displayValue)
1194
+ }
1195
+ }, [inputValue, displayValue, parseInputValue])
1196
+
1197
+ return (
1198
+ <DateSelectorContext.Provider
1199
+ value={{ i18n: mergedI18n, variant: "outline", size: "default" }}
1200
+ >
1201
+ <div className={cn("w-full space-y-4 sm:w-[470px]", className)}>
1202
+ <div className="flex flex-wrap items-center gap-3">
1203
+ {label && (
1204
+ <h3 className="text-sm font-medium" data-slot="data-selector-label">
1205
+ {label}
1206
+ </h3>
1207
+ )}
1208
+ <DateSelectorFilterToggle
1209
+ value={filterType}
1210
+ onChange={setFilterType}
1211
+ showBetween={allowRange}
1212
+ presetMode={presetMode}
1213
+ />
1214
+ </div>
1215
+ {showInput && (
1216
+ <div className="relative">
1217
+ <Input
1218
+ type="text"
1219
+ value={inputHint ? inputValue : displayValue}
1220
+ readOnly={!inputHint}
1221
+ placeholder={
1222
+ isInputFocused && inputHint ? inputHint : mergedI18n.placeholder
1223
+ }
1224
+ onFocus={() => setIsInputFocused(true)}
1225
+ onBlur={handleInputBlur}
1226
+ onChange={handleInputChange}
1227
+ />
1228
+ {(inputHint ? inputValue : displayValue) && (
1229
+ <button
1230
+ type="button"
1231
+ onClick={clearSelection}
1232
+ className={cn(
1233
+ // Base Styles
1234
+ "absolute end-2.5 top-1/2 size-4 -translate-y-1/2 cursor-pointer rounded-xs",
1235
+ // Visual States
1236
+ "opacity-70 transition-opacity hover:opacity-100",
1237
+ // Focus States
1238
+ "ring-offset-background focus:ring-ring focus:ring-2 focus:ring-offset-2 focus:outline-none"
1239
+ )}
1240
+ >
1241
+ <XIcon className="size-4" />
1242
+ </button>
1243
+ )}
1244
+ </div>
1245
+ )}
1246
+ <DateSelectorPeriodTabs
1247
+ value={periodType}
1248
+ onChange={setPeriodType}
1249
+ periodTypes={periodTypes}
1250
+ calendarMonth={calendarMonth}
1251
+ onMonthChange={setCalendarMonth}
1252
+ showNavigationButtons={periodType === "day"}
1253
+ />
1254
+
1255
+ {periodType === "day" ? (
1256
+ <div className="w-full pb-1">
1257
+ <DateSelectorDayPicker
1258
+ currentMonth={calendarMonth}
1259
+ selectedDate={selectedDate}
1260
+ selectedEndDate={selectedEndDate}
1261
+ onDayClick={handleDayClick}
1262
+ isRange={filterType === "between" && allowRange}
1263
+ onDayHover={setHoverDate}
1264
+ hoverDate={hoverDate}
1265
+ showTwoMonths={showTwoMonths}
1266
+ weekStartsOn={weekStartsOn}
1267
+ />
1268
+ </div>
1269
+ ) : (
1270
+ <div className="-mr-3 w-full">
1271
+ <ScrollArea key={periodType} className="h-[200px] w-full pe-3">
1272
+ {periodType === "month" && (
1273
+ <DateSelectorPeriodGrid
1274
+ years={years}
1275
+ items={mergedI18n.monthsShort}
1276
+ selectedYear={selectedYear}
1277
+ selectedValue={selectedMonth}
1278
+ rangeStart={rangeStart}
1279
+ rangeEnd={rangeEnd}
1280
+ isInRange={isInRange}
1281
+ onSelect={handlePeriodSelect}
1282
+ columns={3}
1283
+ />
1284
+ )}
1285
+
1286
+ {periodType === "quarter" && (
1287
+ <DateSelectorPeriodGrid
1288
+ years={years}
1289
+ items={mergedI18n.quarters}
1290
+ selectedYear={selectedYear}
1291
+ selectedValue={selectedQuarter}
1292
+ rangeStart={rangeStart}
1293
+ rangeEnd={rangeEnd}
1294
+ isInRange={isInRange}
1295
+ onSelect={handlePeriodSelect}
1296
+ columns={4}
1297
+ />
1298
+ )}
1299
+
1300
+ {periodType === "half-year" && (
1301
+ <DateSelectorPeriodGrid
1302
+ years={years}
1303
+ items={mergedI18n.halfYears}
1304
+ selectedYear={selectedYear}
1305
+ selectedValue={selectedHalfYear}
1306
+ rangeStart={rangeStart}
1307
+ rangeEnd={rangeEnd}
1308
+ isInRange={isInRange}
1309
+ onSelect={handlePeriodSelect}
1310
+ columns={2}
1311
+ />
1312
+ )}
1313
+
1314
+ {periodType === "year" && (
1315
+ <DateSelectorYearList
1316
+ years={years}
1317
+ selectedYear={selectedYear}
1318
+ rangeStart={rangeStart}
1319
+ rangeEnd={rangeEnd}
1320
+ isYearInRange={isYearInRange}
1321
+ onSelect={handleYearSelect}
1322
+ />
1323
+ )}
1324
+ </ScrollArea>
1325
+ </div>
1326
+ )}
1327
+ </div>
1328
+ </DateSelectorContext.Provider>
1329
+ )
1330
+ }