@sabrenski/spire-ui 0.0.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.
Files changed (237) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/index.d.ts +4981 -0
  4. package/dist/spire-ui.css +1 -0
  5. package/dist/spire-ui.es.js +18403 -0
  6. package/dist/spire-ui.umd.js +45 -0
  7. package/package.json +83 -0
  8. package/src/components/Accordion/Accordion.test.ts +218 -0
  9. package/src/components/Accordion/AccordionContent.vue +112 -0
  10. package/src/components/Accordion/AccordionItem.vue +87 -0
  11. package/src/components/Accordion/AccordionRoot.vue +111 -0
  12. package/src/components/Accordion/AccordionTrigger.vue +125 -0
  13. package/src/components/Accordion/index.ts +11 -0
  14. package/src/components/Accordion/keys.ts +23 -0
  15. package/src/components/Avatar/Avatar.test.ts +181 -0
  16. package/src/components/Avatar/Avatar.vue +150 -0
  17. package/src/components/Avatar/index.ts +2 -0
  18. package/src/components/Badge/Badge.test.ts +141 -0
  19. package/src/components/Badge/Badge.vue +133 -0
  20. package/src/components/Badge/index.ts +2 -0
  21. package/src/components/BadgeContainer/BadgeContainer.test.ts +150 -0
  22. package/src/components/BadgeContainer/BadgeContainer.vue +90 -0
  23. package/src/components/BadgeContainer/index.ts +2 -0
  24. package/src/components/Breadcrumb/Breadcrumb.test.ts +342 -0
  25. package/src/components/Breadcrumb/BreadcrumbEllipsis.vue +96 -0
  26. package/src/components/Breadcrumb/BreadcrumbItem.vue +16 -0
  27. package/src/components/Breadcrumb/BreadcrumbLink.vue +67 -0
  28. package/src/components/Breadcrumb/BreadcrumbList.vue +20 -0
  29. package/src/components/Breadcrumb/BreadcrumbPage.vue +25 -0
  30. package/src/components/Breadcrumb/BreadcrumbRoot.vue +41 -0
  31. package/src/components/Breadcrumb/BreadcrumbSeparator.vue +63 -0
  32. package/src/components/Breadcrumb/index.ts +13 -0
  33. package/src/components/Breadcrumb/keys.ts +7 -0
  34. package/src/components/Button/Button.test.ts +231 -0
  35. package/src/components/Button/Button.vue +349 -0
  36. package/src/components/Button/index.ts +2 -0
  37. package/src/components/Callout/Callout.test.ts +260 -0
  38. package/src/components/Callout/Callout.vue +341 -0
  39. package/src/components/Callout/index.ts +2 -0
  40. package/src/components/Card/Card.test.ts +565 -0
  41. package/src/components/Card/Card.vue +209 -0
  42. package/src/components/Card/CardContent.vue +57 -0
  43. package/src/components/Card/CardFooter.vue +72 -0
  44. package/src/components/Card/CardHeader.vue +111 -0
  45. package/src/components/Card/CardImage.vue +124 -0
  46. package/src/components/Card/index.ts +14 -0
  47. package/src/components/Chart/BarChart.vue +208 -0
  48. package/src/components/Chart/BaseChart.vue +444 -0
  49. package/src/components/Chart/Chart.test.ts +359 -0
  50. package/src/components/Chart/DonutChart.vue +283 -0
  51. package/src/components/Chart/LineChart.vue +211 -0
  52. package/src/components/Chart/index.ts +20 -0
  53. package/src/components/Chart/useChartTheme.ts +192 -0
  54. package/src/components/Checkbox/Checkbox.test.ts +209 -0
  55. package/src/components/Checkbox/Checkbox.vue +285 -0
  56. package/src/components/Checkbox/index.ts +2 -0
  57. package/src/components/ChoiceChip/ChoiceChip.test.ts +142 -0
  58. package/src/components/ChoiceChip/ChoiceChip.vue +218 -0
  59. package/src/components/ChoiceChip/index.ts +2 -0
  60. package/src/components/ChoiceChipGroup/ChoiceChipGroup.test.ts +151 -0
  61. package/src/components/ChoiceChipGroup/ChoiceChipGroup.vue +70 -0
  62. package/src/components/ChoiceChipGroup/index.ts +2 -0
  63. package/src/components/ColorPicker/ColorArea.vue +159 -0
  64. package/src/components/ColorPicker/ColorPicker.test.ts +250 -0
  65. package/src/components/ColorPicker/ColorPicker.vue +339 -0
  66. package/src/components/ColorPicker/ColorSlider.vue +191 -0
  67. package/src/components/ColorPicker/index.ts +7 -0
  68. package/src/components/Combobox/Combobox.test.ts +891 -0
  69. package/src/components/Combobox/Combobox.vue +934 -0
  70. package/src/components/Combobox/index.ts +2 -0
  71. package/src/components/DataTable/DataTable.test.ts +1221 -0
  72. package/src/components/DataTable/DataTable.vue +1415 -0
  73. package/src/components/DataTable/index.ts +10 -0
  74. package/src/components/DatePicker/DatePicker.test.ts +625 -0
  75. package/src/components/DatePicker/DatePicker.vue +1586 -0
  76. package/src/components/DatePicker/index.ts +2 -0
  77. package/src/components/Drawer/Drawer.test.ts +336 -0
  78. package/src/components/Drawer/Drawer.vue +466 -0
  79. package/src/components/Drawer/index.ts +2 -0
  80. package/src/components/Dropdown/Dropdown.test.ts +607 -0
  81. package/src/components/Dropdown/Dropdown.vue +807 -0
  82. package/src/components/Dropdown/DropdownItem.vue +227 -0
  83. package/src/components/Dropdown/DropdownSeparator.vue +14 -0
  84. package/src/components/Dropdown/DropdownSub.vue +104 -0
  85. package/src/components/Dropdown/DropdownSubContent.vue +187 -0
  86. package/src/components/Dropdown/DropdownSubTrigger.vue +151 -0
  87. package/src/components/Dropdown/index.ts +14 -0
  88. package/src/components/EmptyState/EmptyState.test.ts +180 -0
  89. package/src/components/EmptyState/EmptyState.vue +137 -0
  90. package/src/components/EmptyState/index.ts +2 -0
  91. package/src/components/FileUpload/FileUpload.test.ts +1151 -0
  92. package/src/components/FileUpload/FileUpload.vue +1042 -0
  93. package/src/components/FileUpload/index.ts +2 -0
  94. package/src/components/Heading/Heading.test.ts +107 -0
  95. package/src/components/Heading/Heading.vue +67 -0
  96. package/src/components/Heading/index.ts +2 -0
  97. package/src/components/Icon/Icon.test.ts +157 -0
  98. package/src/components/Icon/Icon.vue +86 -0
  99. package/src/components/Icon/index.ts +2 -0
  100. package/src/components/Input/Input.test.ts +273 -0
  101. package/src/components/Input/Input.vue +388 -0
  102. package/src/components/Input/index.ts +2 -0
  103. package/src/components/Layout/Container.vue +67 -0
  104. package/src/components/Layout/Grid.vue +159 -0
  105. package/src/components/Layout/GridItem.vue +154 -0
  106. package/src/components/Layout/Layout.test.ts +202 -0
  107. package/src/components/Layout/Stack.vue +128 -0
  108. package/src/components/Layout/index.ts +9 -0
  109. package/src/components/Layout/keys.ts +7 -0
  110. package/src/components/Modal/Modal.test.ts +311 -0
  111. package/src/components/Modal/Modal.vue +336 -0
  112. package/src/components/Modal/index.ts +2 -0
  113. package/src/components/Pagination/Pagination.test.ts +303 -0
  114. package/src/components/Pagination/Pagination.vue +212 -0
  115. package/src/components/Pagination/index.ts +3 -0
  116. package/src/components/Pagination/utils.ts +86 -0
  117. package/src/components/Popover/Popover.test.ts +285 -0
  118. package/src/components/Popover/Popover.vue +441 -0
  119. package/src/components/Popover/index.ts +2 -0
  120. package/src/components/Progress/Progress.test.ts +361 -0
  121. package/src/components/Progress/Progress.vue +363 -0
  122. package/src/components/Progress/index.ts +7 -0
  123. package/src/components/Radio/Radio.test.ts +216 -0
  124. package/src/components/Radio/Radio.vue +214 -0
  125. package/src/components/Radio/index.ts +2 -0
  126. package/src/components/Rating/Rating.test.ts +319 -0
  127. package/src/components/Rating/Rating.vue +247 -0
  128. package/src/components/Rating/index.ts +2 -0
  129. package/src/components/SegmentedControl/SegmentedControl.test.ts +292 -0
  130. package/src/components/SegmentedControl/SegmentedControl.vue +288 -0
  131. package/src/components/SegmentedControl/index.ts +2 -0
  132. package/src/components/Select/Select.test.ts +589 -0
  133. package/src/components/Select/Select.vue +666 -0
  134. package/src/components/Select/index.ts +2 -0
  135. package/src/components/Sidebar/Sidebar.test.ts +301 -0
  136. package/src/components/Sidebar/SidebarGroup.vue +103 -0
  137. package/src/components/Sidebar/SidebarItem.vue +196 -0
  138. package/src/components/Sidebar/SidebarLayout.vue +42 -0
  139. package/src/components/Sidebar/SidebarRoot.vue +122 -0
  140. package/src/components/Sidebar/index.ts +11 -0
  141. package/src/components/Sidebar/keys.ts +14 -0
  142. package/src/components/Skeleton/Skeleton.test.ts +130 -0
  143. package/src/components/Skeleton/Skeleton.vue +104 -0
  144. package/src/components/Skeleton/index.ts +2 -0
  145. package/src/components/Slider/Slider.test.ts +416 -0
  146. package/src/components/Slider/Slider.vue +435 -0
  147. package/src/components/Slider/index.ts +2 -0
  148. package/src/components/Slider/utils.ts +91 -0
  149. package/src/components/Spinner/Spinner.test.ts +79 -0
  150. package/src/components/Spinner/Spinner.vue +159 -0
  151. package/src/components/Spinner/index.ts +2 -0
  152. package/src/components/SpireProvider/SpireProvider.vue +71 -0
  153. package/src/components/SpireProvider/index.ts +11 -0
  154. package/src/components/Stepper/Stepper.test.ts +221 -0
  155. package/src/components/Stepper/StepperContent.vue +51 -0
  156. package/src/components/Stepper/StepperItem.vue +89 -0
  157. package/src/components/Stepper/StepperRoot.vue +101 -0
  158. package/src/components/Stepper/StepperSeparator.vue +52 -0
  159. package/src/components/Stepper/StepperTrigger.vue +144 -0
  160. package/src/components/Stepper/index.ts +11 -0
  161. package/src/components/Stepper/keys.ts +27 -0
  162. package/src/components/Switch/Switch.test.ts +214 -0
  163. package/src/components/Switch/Switch.vue +235 -0
  164. package/src/components/Switch/index.ts +2 -0
  165. package/src/components/Tabs/Tabs.test.ts +363 -0
  166. package/src/components/Tabs/Tabs.vue +318 -0
  167. package/src/components/Tabs/index.ts +2 -0
  168. package/src/components/Text/Text.test.ts +154 -0
  169. package/src/components/Text/Text.vue +100 -0
  170. package/src/components/Text/index.ts +2 -0
  171. package/src/components/Textarea/Textarea.test.ts +432 -0
  172. package/src/components/Textarea/Textarea.vue +411 -0
  173. package/src/components/Textarea/index.ts +2 -0
  174. package/src/components/TimePicker/TimePicker.test.ts +352 -0
  175. package/src/components/TimePicker/TimePicker.vue +569 -0
  176. package/src/components/TimePicker/index.ts +2 -0
  177. package/src/components/Timeline/Timeline.test.ts +193 -0
  178. package/src/components/Timeline/Timeline.vue +111 -0
  179. package/src/components/Timeline/TimelineItem.vue +167 -0
  180. package/src/components/Timeline/index.ts +13 -0
  181. package/src/components/Timeline/keys.ts +21 -0
  182. package/src/components/Toast/ToastItem.test.ts +289 -0
  183. package/src/components/Toast/ToastItem.vue +370 -0
  184. package/src/components/Toast/ToastProvider.test.ts +158 -0
  185. package/src/components/Toast/ToastProvider.vue +181 -0
  186. package/src/components/Toast/index.ts +83 -0
  187. package/src/components/Toast/toastState.test.ts +165 -0
  188. package/src/components/Toast/toastState.ts +161 -0
  189. package/src/components/ToggleButton/ToggleButton.test.ts +166 -0
  190. package/src/components/ToggleButton/ToggleButton.vue +197 -0
  191. package/src/components/ToggleButton/index.ts +2 -0
  192. package/src/components/ToggleGroup/ToggleGroup.test.ts +181 -0
  193. package/src/components/ToggleGroup/ToggleGroup.vue +130 -0
  194. package/src/components/ToggleGroup/index.ts +2 -0
  195. package/src/components/Tooltip/Tooltip.test.ts +238 -0
  196. package/src/components/Tooltip/Tooltip.vue +217 -0
  197. package/src/components/Tooltip/index.ts +2 -0
  198. package/src/components/TreeView/TreeView.test.ts +357 -0
  199. package/src/components/TreeView/TreeView.vue +251 -0
  200. package/src/components/TreeView/TreeViewItem.vue +288 -0
  201. package/src/components/TreeView/index.ts +11 -0
  202. package/src/components/TreeView/keys.ts +35 -0
  203. package/src/composables/index.ts +12 -0
  204. package/src/composables/useClickOutside.ts +36 -0
  205. package/src/composables/useClipboard.ts +35 -0
  206. package/src/composables/useEventListener.ts +48 -0
  207. package/src/composables/useFocusTrap.ts +58 -0
  208. package/src/composables/useHoverReveal.ts +98 -0
  209. package/src/composables/useId.ts +10 -0
  210. package/src/composables/useMagnetic.ts +171 -0
  211. package/src/composables/useRelativePosition.ts +127 -0
  212. package/src/composables/useRipple.ts +146 -0
  213. package/src/composables/useScrollLock.ts +25 -0
  214. package/src/composables/useSpireConfig.ts +27 -0
  215. package/src/composables/useStagger.ts +224 -0
  216. package/src/config/icons.test.ts +115 -0
  217. package/src/config/icons.ts +170 -0
  218. package/src/index.ts +361 -0
  219. package/src/styles/depth.css +129 -0
  220. package/src/styles/effects.css +169 -0
  221. package/src/styles/fallback.css +152 -0
  222. package/src/styles/main.css +25 -0
  223. package/src/styles/mood.css +211 -0
  224. package/src/styles/motion.css +159 -0
  225. package/src/styles/reset.css +97 -0
  226. package/src/styles/theme.css +708 -0
  227. package/src/styles/tokens.css +183 -0
  228. package/src/utils/.gitkeep +0 -0
  229. package/src/utils/color.ts +277 -0
  230. package/src/utils/date.test.ts +522 -0
  231. package/src/utils/date.ts +380 -0
  232. package/src/utils/index.ts +23 -0
  233. package/src/utils/object.test.ts +80 -0
  234. package/src/utils/object.ts +25 -0
  235. package/src/utils/string.test.ts +64 -0
  236. package/src/utils/string.ts +32 -0
  237. package/src/utils/time.ts +156 -0
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Calendar grid item representing a single day cell.
3
+ */
4
+ export interface CalendarDay {
5
+ /** The date object for this cell */
6
+ date: Date
7
+ /** Day of the month (1-31) */
8
+ day: number
9
+ /** Whether this day belongs to the currently viewed month */
10
+ currentMonth: boolean
11
+ }
12
+
13
+ /**
14
+ * Generate a 42-item grid (6 rows × 7 columns) for a calendar month view.
15
+ * The grid always starts on Sunday and includes days from previous/next months
16
+ * to fill the complete 6-row layout.
17
+ *
18
+ * @param year - Full year (e.g., 2024)
19
+ * @param month - Month (0-11, where 0 = January)
20
+ * @returns Array of 42 CalendarDay objects
21
+ *
22
+ * Time complexity: O(1) - Fixed 42 iterations
23
+ * Space complexity: O(1) - Fixed 42-element array
24
+ */
25
+ export function generateCalendarGrid(year: number, month: number): CalendarDay[] {
26
+ const grid: CalendarDay[] = []
27
+ const firstDayOfMonth = new Date(year, month, 1)
28
+ const startDayOfWeek = firstDayOfMonth.getDay()
29
+ const startDate = new Date(year, month, 1 - startDayOfWeek)
30
+
31
+ for (let i = 0; i < 42; i++) {
32
+ const date = new Date(startDate)
33
+ date.setDate(startDate.getDate() + i)
34
+
35
+ grid.push({
36
+ date,
37
+ day: date.getDate(),
38
+ currentMonth: date.getMonth() === month
39
+ })
40
+ }
41
+
42
+ return grid
43
+ }
44
+
45
+ /**
46
+ * Get the number of days in a given month, correctly handling leap years.
47
+ *
48
+ * @param year - Full year (e.g., 2024)
49
+ * @param month - Month (0-11, where 0 = January)
50
+ * @returns Number of days in the month (28-31)
51
+ *
52
+ * Time complexity: O(1)
53
+ * Space complexity: O(1)
54
+ */
55
+ export function getDaysInMonth(year: number, month: number): number {
56
+ return new Date(year, month + 1, 0).getDate()
57
+ }
58
+
59
+ /**
60
+ * Format a Date object to ISO 8601 date string (YYYY-MM-DD).
61
+ *
62
+ * @param date - Date object to format
63
+ * @returns ISO date string in YYYY-MM-DD format
64
+ *
65
+ * Time complexity: O(1)
66
+ * Space complexity: O(1)
67
+ */
68
+ export function formatDate(date: Date): string {
69
+ const year = date.getFullYear()
70
+ const month = String(date.getMonth() + 1).padStart(2, '0')
71
+ const day = String(date.getDate()).padStart(2, '0')
72
+ return `${year}-${month}-${day}`
73
+ }
74
+
75
+ /**
76
+ * Parse an ISO 8601 date string (YYYY-MM-DD) to a Date object.
77
+ * Returns null for invalid input.
78
+ *
79
+ * @param dateString - ISO date string in YYYY-MM-DD format
80
+ * @returns Date object or null if invalid
81
+ *
82
+ * Time complexity: O(1)
83
+ * Space complexity: O(1)
84
+ */
85
+ export function parseDate(dateString: string): Date | null {
86
+ if (!dateString || !/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
87
+ return null
88
+ }
89
+
90
+ const [year, month, day] = dateString.split('-').map(Number)
91
+ const date = new Date(year, month - 1, day)
92
+
93
+ if (
94
+ date.getFullYear() !== year ||
95
+ date.getMonth() !== month - 1 ||
96
+ date.getDate() !== day
97
+ ) {
98
+ return null
99
+ }
100
+
101
+ return date
102
+ }
103
+
104
+ /**
105
+ * Check if two dates represent the same calendar day (ignoring time).
106
+ *
107
+ * @param d1 - First date
108
+ * @param d2 - Second date
109
+ * @returns True if both dates are the same day
110
+ *
111
+ * Time complexity: O(1)
112
+ * Space complexity: O(1)
113
+ */
114
+ export function isSameDate(d1: Date | null, d2: Date | null): boolean {
115
+ if (!d1 || !d2) return false
116
+
117
+ return (
118
+ d1.getFullYear() === d2.getFullYear() &&
119
+ d1.getMonth() === d2.getMonth() &&
120
+ d1.getDate() === d2.getDate()
121
+ )
122
+ }
123
+
124
+ /**
125
+ * Check if a date is before another date (day-level comparison).
126
+ *
127
+ * @param date - Date to check
128
+ * @param reference - Reference date
129
+ * @returns True if date is before reference
130
+ *
131
+ * Time complexity: O(1)
132
+ * Space complexity: O(1)
133
+ */
134
+ export function isDateBefore(date: Date, reference: Date): boolean {
135
+ const d = new Date(date.getFullYear(), date.getMonth(), date.getDate())
136
+ const r = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate())
137
+ return d.getTime() < r.getTime()
138
+ }
139
+
140
+ /**
141
+ * Check if a date is after another date (day-level comparison).
142
+ *
143
+ * @param date - Date to check
144
+ * @param reference - Reference date
145
+ * @returns True if date is after reference
146
+ *
147
+ * Time complexity: O(1)
148
+ * Space complexity: O(1)
149
+ */
150
+ export function isDateAfter(date: Date, reference: Date): boolean {
151
+ const d = new Date(date.getFullYear(), date.getMonth(), date.getDate())
152
+ const r = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate())
153
+ return d.getTime() > r.getTime()
154
+ }
155
+
156
+ /**
157
+ * Get localized month name.
158
+ *
159
+ * @param month - Month (0-11)
160
+ * @param locale - Locale string (defaults to browser locale)
161
+ * @returns Localized month name
162
+ *
163
+ * Time complexity: O(1)
164
+ * Space complexity: O(1)
165
+ */
166
+ export function getMonthName(month: number, locale?: string): string {
167
+ const date = new Date(2000, month, 1)
168
+ return date.toLocaleDateString(locale, { month: 'long' })
169
+ }
170
+
171
+ /**
172
+ * Get localized weekday abbreviations starting from Sunday.
173
+ *
174
+ * @param locale - Locale string (defaults to browser locale)
175
+ * @returns Array of 7 weekday abbreviations
176
+ *
177
+ * Time complexity: O(1) - Fixed 7 iterations
178
+ * Space complexity: O(1) - Fixed 7-element array
179
+ */
180
+ export function getWeekdayNames(locale?: string): string[] {
181
+ const weekdays: string[] = []
182
+ const baseDate = new Date(2024, 0, 7)
183
+
184
+ for (let i = 0; i < 7; i++) {
185
+ const date = new Date(baseDate)
186
+ date.setDate(baseDate.getDate() + i)
187
+ weekdays.push(date.toLocaleDateString(locale, { weekday: 'short' }))
188
+ }
189
+
190
+ return weekdays
191
+ }
192
+
193
+ /**
194
+ * Check if a date falls within an optional min/max range.
195
+ *
196
+ * @param date - Date to check
197
+ * @param min - Minimum allowed date (inclusive)
198
+ * @param max - Maximum allowed date (inclusive)
199
+ * @returns True if date is within range or no constraints exist
200
+ *
201
+ * Time complexity: O(1)
202
+ * Space complexity: O(1)
203
+ */
204
+ export function isDateInRange(date: Date, min?: Date | null, max?: Date | null): boolean {
205
+ if (min && isDateBefore(date, min)) return false
206
+ if (max && isDateAfter(date, max)) return false
207
+ return true
208
+ }
209
+
210
+ /**
211
+ * Check if a date falls strictly between two dates (exclusive).
212
+ *
213
+ * @param date - Date to check
214
+ * @param start - Range start date
215
+ * @param end - Range end date
216
+ * @returns True if date is between start and end (exclusive)
217
+ *
218
+ * Time complexity: O(1)
219
+ * Space complexity: O(1)
220
+ */
221
+ export function isDateBetween(date: Date, start: Date | null, end: Date | null): boolean {
222
+ if (!start || !end) return false
223
+ const d = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
224
+ const s = new Date(start.getFullYear(), start.getMonth(), start.getDate()).getTime()
225
+ const e = new Date(end.getFullYear(), end.getMonth(), end.getDate()).getTime()
226
+ return d > Math.min(s, e) && d < Math.max(s, e)
227
+ }
228
+
229
+ /**
230
+ * Range selection state for two-click selection.
231
+ */
232
+ export type RangeState = 'idle' | 'selecting'
233
+
234
+ /**
235
+ * Computed range class for a date cell in range mode.
236
+ */
237
+ export type RangeClass = 'start' | 'end' | 'in-range' | null
238
+
239
+ /**
240
+ * Determine the CSS class for a date in range selection mode.
241
+ * Handles the "flip" when user selects end date before start date.
242
+ *
243
+ * @param date - Date to classify
244
+ * @param start - Selected start date (or first click)
245
+ * @param end - Selected end date (or null if selecting)
246
+ * @param hover - Currently hovered date (for preview during selection)
247
+ * @returns Range class for styling
248
+ *
249
+ * Time complexity: O(1)
250
+ * Space complexity: O(1)
251
+ */
252
+ export function getRangeClass(
253
+ date: Date,
254
+ start: Date | null,
255
+ end: Date | null,
256
+ hover: Date | null
257
+ ): RangeClass {
258
+ if (!start) return null
259
+
260
+ const effectiveEnd = end || hover
261
+ if (!effectiveEnd) {
262
+ return isSameDate(date, start) ? 'start' : null
263
+ }
264
+
265
+ const startTime = new Date(start.getFullYear(), start.getMonth(), start.getDate()).getTime()
266
+ const endTime = new Date(effectiveEnd.getFullYear(), effectiveEnd.getMonth(), effectiveEnd.getDate()).getTime()
267
+ const dateTime = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
268
+
269
+ const rangeStart = Math.min(startTime, endTime)
270
+ const rangeEnd = Math.max(startTime, endTime)
271
+
272
+ if (dateTime === rangeStart) return 'start'
273
+ if (dateTime === rangeEnd) return 'end'
274
+ if (dateTime > rangeStart && dateTime < rangeEnd) return 'in-range'
275
+
276
+ return null
277
+ }
278
+
279
+ /**
280
+ * Normalize a range to ensure start <= end.
281
+ *
282
+ * @param start - First selected date
283
+ * @param end - Second selected date
284
+ * @returns Tuple [start, end] in chronological order
285
+ *
286
+ * Time complexity: O(1)
287
+ * Space complexity: O(1)
288
+ */
289
+ export function normalizeRange(start: Date, end: Date): [Date, Date] {
290
+ const s = new Date(start.getFullYear(), start.getMonth(), start.getDate()).getTime()
291
+ const e = new Date(end.getFullYear(), end.getMonth(), end.getDate()).getTime()
292
+ return s <= e ? [start, end] : [end, start]
293
+ }
294
+
295
+ /**
296
+ * Get all month names for a locale (short format for month grid).
297
+ *
298
+ * @param locale - Locale string (defaults to browser locale)
299
+ * @returns Array of 12 short month names
300
+ *
301
+ * Time complexity: O(1) - Fixed 12 iterations
302
+ * Space complexity: O(1) - Fixed 12-element array
303
+ */
304
+ export function getMonthNamesShort(locale?: string): string[] {
305
+ const months: string[] = []
306
+ for (let i = 0; i < 12; i++) {
307
+ const date = new Date(2000, i, 1)
308
+ months.push(date.toLocaleDateString(locale, { month: 'short' }))
309
+ }
310
+ return months
311
+ }
312
+
313
+ /**
314
+ * Generate a 20-year window centered around a given year for year grid view.
315
+ *
316
+ * @param centerYear - Year to center the window around
317
+ * @returns Array of 20 years [start, start+1, ..., start+19]
318
+ *
319
+ * Time complexity: O(1) - Fixed 20 iterations
320
+ * Space complexity: O(1) - Fixed 20-element array
321
+ */
322
+ export function generateYearGrid(centerYear: number): number[] {
323
+ const startYear = Math.floor(centerYear / 20) * 20
324
+ return Array.from({ length: 20 }, (_, i) => startYear + i)
325
+ }
326
+
327
+ /**
328
+ * Get the start of a date range preset.
329
+ *
330
+ * @param preset - Preset name
331
+ * @returns [start, end] date tuple
332
+ */
333
+ export function getPresetRange(preset: string): [Date, Date] {
334
+ const today = new Date()
335
+ const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate())
336
+
337
+ switch (preset) {
338
+ case 'today':
339
+ return [todayStart, todayStart]
340
+
341
+ case 'yesterday': {
342
+ const yesterday = new Date(todayStart)
343
+ yesterday.setDate(yesterday.getDate() - 1)
344
+ return [yesterday, yesterday]
345
+ }
346
+
347
+ case 'last7days': {
348
+ const start = new Date(todayStart)
349
+ start.setDate(start.getDate() - 6)
350
+ return [start, todayStart]
351
+ }
352
+
353
+ case 'last30days': {
354
+ const start = new Date(todayStart)
355
+ start.setDate(start.getDate() - 29)
356
+ return [start, todayStart]
357
+ }
358
+
359
+ case 'thisMonth': {
360
+ const start = new Date(today.getFullYear(), today.getMonth(), 1)
361
+ const end = new Date(today.getFullYear(), today.getMonth() + 1, 0)
362
+ return [start, end]
363
+ }
364
+
365
+ case 'lastMonth': {
366
+ const start = new Date(today.getFullYear(), today.getMonth() - 1, 1)
367
+ const end = new Date(today.getFullYear(), today.getMonth(), 0)
368
+ return [start, end]
369
+ }
370
+
371
+ case 'thisYear': {
372
+ const start = new Date(today.getFullYear(), 0, 1)
373
+ const end = new Date(today.getFullYear(), 11, 31)
374
+ return [start, end]
375
+ }
376
+
377
+ default:
378
+ return [todayStart, todayStart]
379
+ }
380
+ }
@@ -0,0 +1,23 @@
1
+ export { getInitials } from './string'
2
+ export { getNestedValue } from './object'
3
+ export {
4
+ generateCalendarGrid,
5
+ getDaysInMonth,
6
+ formatDate,
7
+ parseDate,
8
+ isSameDate,
9
+ isDateBefore,
10
+ isDateAfter,
11
+ getMonthName,
12
+ getMonthNamesShort,
13
+ getWeekdayNames,
14
+ isDateInRange,
15
+ isDateBetween,
16
+ getRangeClass,
17
+ normalizeRange,
18
+ generateYearGrid,
19
+ getPresetRange,
20
+ type CalendarDay,
21
+ type RangeState,
22
+ type RangeClass
23
+ } from './date'
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { getNestedValue } from './object'
3
+
4
+ describe('getNestedValue', () => {
5
+ it('returns undefined for empty path', () => {
6
+ expect(getNestedValue({ name: 'John' }, '')).toBeUndefined()
7
+ })
8
+
9
+ it('returns top-level property', () => {
10
+ expect(getNestedValue({ name: 'John' }, 'name')).toBe('John')
11
+ })
12
+
13
+ it('returns nested property', () => {
14
+ const obj = { user: { email: 'a@b.com' } }
15
+ expect(getNestedValue(obj, 'user.email')).toBe('a@b.com')
16
+ })
17
+
18
+ it('returns deeply nested property', () => {
19
+ const obj = { a: { b: { c: { d: 'deep' } } } }
20
+ expect(getNestedValue(obj, 'a.b.c.d')).toBe('deep')
21
+ })
22
+
23
+ it('returns undefined for missing property', () => {
24
+ expect(getNestedValue({}, 'missing')).toBeUndefined()
25
+ })
26
+
27
+ it('returns undefined for missing nested property', () => {
28
+ expect(getNestedValue({ user: {} }, 'user.email')).toBeUndefined()
29
+ })
30
+
31
+ it('returns undefined when intermediate path is missing', () => {
32
+ expect(getNestedValue({}, 'user.email.domain')).toBeUndefined()
33
+ })
34
+
35
+ it('handles null values in path', () => {
36
+ const obj = { user: null }
37
+ expect(getNestedValue(obj as Record<string, unknown>, 'user.email')).toBeUndefined()
38
+ })
39
+
40
+ it('returns arrays', () => {
41
+ const obj = { items: [1, 2, 3] }
42
+ expect(getNestedValue(obj, 'items')).toEqual([1, 2, 3])
43
+ })
44
+
45
+ it('returns objects', () => {
46
+ const obj = { user: { name: 'John', age: 30 } }
47
+ expect(getNestedValue(obj, 'user')).toEqual({ name: 'John', age: 30 })
48
+ })
49
+
50
+ it('returns boolean false', () => {
51
+ const obj = { active: false }
52
+ expect(getNestedValue(obj, 'active')).toBe(false)
53
+ })
54
+
55
+ it('returns number zero', () => {
56
+ const obj = { count: 0 }
57
+ expect(getNestedValue(obj, 'count')).toBe(0)
58
+ })
59
+
60
+ it('returns empty string', () => {
61
+ const obj = { name: '' }
62
+ expect(getNestedValue(obj, 'name')).toBe('')
63
+ })
64
+
65
+ it('blocks prototype chain access via __proto__', () => {
66
+ const obj = { safe: 'value' }
67
+ expect(getNestedValue(obj, '__proto__')).toBeUndefined()
68
+ })
69
+
70
+ it('blocks constructor access', () => {
71
+ const obj = { safe: 'value' }
72
+ expect(getNestedValue(obj, 'constructor')).toBeUndefined()
73
+ })
74
+
75
+ it('blocks nested prototype traversal', () => {
76
+ const obj = { nested: { value: 'test' } }
77
+ expect(getNestedValue(obj, 'nested.__proto__')).toBeUndefined()
78
+ expect(getNestedValue(obj, 'nested.constructor')).toBeUndefined()
79
+ })
80
+ })
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Access a nested property in an object using dot notation.
3
+ *
4
+ * @param obj - The object to access
5
+ * @param path - Dot-notation path (e.g., 'user.email', 'address.city')
6
+ * @returns The value at the path, or undefined if not found
7
+ *
8
+ * @example
9
+ * getNestedValue({ user: { email: 'a@b.com' } }, 'user.email') // 'a@b.com'
10
+ * getNestedValue({ name: 'John' }, 'name') // 'John'
11
+ * getNestedValue({}, 'missing.path') // undefined
12
+ */
13
+ export function getNestedValue(
14
+ obj: Record<string, unknown>,
15
+ path: string
16
+ ): unknown {
17
+ if (!path) return undefined
18
+
19
+ return path.split('.').reduce<unknown>((acc, key) => {
20
+ if (acc && typeof acc === 'object' && Object.hasOwn(acc as object, key)) {
21
+ return (acc as Record<string, unknown>)[key]
22
+ }
23
+ return undefined
24
+ }, obj)
25
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { getInitials } from './string'
3
+
4
+ describe('getInitials', () => {
5
+ describe('basic functionality', () => {
6
+ it('returns initials for two-word name', () => {
7
+ expect(getInitials('John Doe')).toBe('JD')
8
+ })
9
+
10
+ it('returns initials for single name (first N chars)', () => {
11
+ expect(getInitials('Madonna')).toBe('MA')
12
+ })
13
+
14
+ it('returns first and last initials for multi-word names', () => {
15
+ expect(getInitials('John von Doe')).toBe('JD')
16
+ expect(getInitials('Mary Jane Watson')).toBe('MW')
17
+ })
18
+ })
19
+
20
+ describe('edge cases', () => {
21
+ it('returns empty string for undefined', () => {
22
+ expect(getInitials(undefined)).toBe('')
23
+ })
24
+
25
+ it('returns empty string for empty string', () => {
26
+ expect(getInitials('')).toBe('')
27
+ })
28
+
29
+ it('returns empty string for whitespace only', () => {
30
+ expect(getInitials(' ')).toBe('')
31
+ })
32
+
33
+ it('handles multiple spaces between words', () => {
34
+ expect(getInitials('John Doe')).toBe('JD')
35
+ })
36
+
37
+ it('handles leading/trailing whitespace', () => {
38
+ expect(getInitials(' John Doe ')).toBe('JD')
39
+ })
40
+
41
+ it('handles single character name', () => {
42
+ expect(getInitials('X')).toBe('X')
43
+ })
44
+ })
45
+
46
+ describe('limit parameter', () => {
47
+ it('respects custom limit for single names', () => {
48
+ expect(getInitials('Madonna', 1)).toBe('M')
49
+ expect(getInitials('Madonna', 3)).toBe('MAD')
50
+ })
51
+
52
+ it('limit does not affect multi-word names (always 2 chars)', () => {
53
+ expect(getInitials('John Doe', 1)).toBe('JD')
54
+ })
55
+ })
56
+
57
+ describe('case handling', () => {
58
+ it('returns uppercase regardless of input case', () => {
59
+ expect(getInitials('john doe')).toBe('JD')
60
+ expect(getInitials('JOHN DOE')).toBe('JD')
61
+ expect(getInitials('jOhN dOe')).toBe('JD')
62
+ })
63
+ })
64
+ })
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Extract initials from a name string.
3
+ *
4
+ * @param name - Full name (e.g., "John Doe", "Madonna", "John von Doe")
5
+ * @param limit - Maximum characters to return (default: 2)
6
+ * @returns Uppercase initials (e.g., "JD", "M", "JD")
7
+ *
8
+ * @example
9
+ * getInitials("John Doe") // "JD"
10
+ * getInitials("Madonna") // "MA"
11
+ * getInitials("John von Doe") // "JD" (first + last)
12
+ * getInitials("") // ""
13
+ */
14
+ export function getInitials(name?: string, limit = 2): string {
15
+ if (!name) return ''
16
+
17
+ // Split by spaces, filter empty (handles "John Doe")
18
+ const parts = name.trim().split(/\s+/)
19
+
20
+ if (parts.length === 0 || parts[0] === '') return ''
21
+
22
+ // Single name "Madonna" -> "MA" (first `limit` chars)
23
+ if (parts.length === 1) {
24
+ return parts[0].substring(0, limit).toUpperCase()
25
+ }
26
+
27
+ // Multi name "John von Doe" -> "JD" (First and Last)
28
+ const first = parts[0][0]
29
+ const last = parts[parts.length - 1][0]
30
+
31
+ return (first + last).toUpperCase()
32
+ }