@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.
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/index.d.ts +4981 -0
- package/dist/spire-ui.css +1 -0
- package/dist/spire-ui.es.js +18403 -0
- package/dist/spire-ui.umd.js +45 -0
- package/package.json +83 -0
- package/src/components/Accordion/Accordion.test.ts +218 -0
- package/src/components/Accordion/AccordionContent.vue +112 -0
- package/src/components/Accordion/AccordionItem.vue +87 -0
- package/src/components/Accordion/AccordionRoot.vue +111 -0
- package/src/components/Accordion/AccordionTrigger.vue +125 -0
- package/src/components/Accordion/index.ts +11 -0
- package/src/components/Accordion/keys.ts +23 -0
- package/src/components/Avatar/Avatar.test.ts +181 -0
- package/src/components/Avatar/Avatar.vue +150 -0
- package/src/components/Avatar/index.ts +2 -0
- package/src/components/Badge/Badge.test.ts +141 -0
- package/src/components/Badge/Badge.vue +133 -0
- package/src/components/Badge/index.ts +2 -0
- package/src/components/BadgeContainer/BadgeContainer.test.ts +150 -0
- package/src/components/BadgeContainer/BadgeContainer.vue +90 -0
- package/src/components/BadgeContainer/index.ts +2 -0
- package/src/components/Breadcrumb/Breadcrumb.test.ts +342 -0
- package/src/components/Breadcrumb/BreadcrumbEllipsis.vue +96 -0
- package/src/components/Breadcrumb/BreadcrumbItem.vue +16 -0
- package/src/components/Breadcrumb/BreadcrumbLink.vue +67 -0
- package/src/components/Breadcrumb/BreadcrumbList.vue +20 -0
- package/src/components/Breadcrumb/BreadcrumbPage.vue +25 -0
- package/src/components/Breadcrumb/BreadcrumbRoot.vue +41 -0
- package/src/components/Breadcrumb/BreadcrumbSeparator.vue +63 -0
- package/src/components/Breadcrumb/index.ts +13 -0
- package/src/components/Breadcrumb/keys.ts +7 -0
- package/src/components/Button/Button.test.ts +231 -0
- package/src/components/Button/Button.vue +349 -0
- package/src/components/Button/index.ts +2 -0
- package/src/components/Callout/Callout.test.ts +260 -0
- package/src/components/Callout/Callout.vue +341 -0
- package/src/components/Callout/index.ts +2 -0
- package/src/components/Card/Card.test.ts +565 -0
- package/src/components/Card/Card.vue +209 -0
- package/src/components/Card/CardContent.vue +57 -0
- package/src/components/Card/CardFooter.vue +72 -0
- package/src/components/Card/CardHeader.vue +111 -0
- package/src/components/Card/CardImage.vue +124 -0
- package/src/components/Card/index.ts +14 -0
- package/src/components/Chart/BarChart.vue +208 -0
- package/src/components/Chart/BaseChart.vue +444 -0
- package/src/components/Chart/Chart.test.ts +359 -0
- package/src/components/Chart/DonutChart.vue +283 -0
- package/src/components/Chart/LineChart.vue +211 -0
- package/src/components/Chart/index.ts +20 -0
- package/src/components/Chart/useChartTheme.ts +192 -0
- package/src/components/Checkbox/Checkbox.test.ts +209 -0
- package/src/components/Checkbox/Checkbox.vue +285 -0
- package/src/components/Checkbox/index.ts +2 -0
- package/src/components/ChoiceChip/ChoiceChip.test.ts +142 -0
- package/src/components/ChoiceChip/ChoiceChip.vue +218 -0
- package/src/components/ChoiceChip/index.ts +2 -0
- package/src/components/ChoiceChipGroup/ChoiceChipGroup.test.ts +151 -0
- package/src/components/ChoiceChipGroup/ChoiceChipGroup.vue +70 -0
- package/src/components/ChoiceChipGroup/index.ts +2 -0
- package/src/components/ColorPicker/ColorArea.vue +159 -0
- package/src/components/ColorPicker/ColorPicker.test.ts +250 -0
- package/src/components/ColorPicker/ColorPicker.vue +339 -0
- package/src/components/ColorPicker/ColorSlider.vue +191 -0
- package/src/components/ColorPicker/index.ts +7 -0
- package/src/components/Combobox/Combobox.test.ts +891 -0
- package/src/components/Combobox/Combobox.vue +934 -0
- package/src/components/Combobox/index.ts +2 -0
- package/src/components/DataTable/DataTable.test.ts +1221 -0
- package/src/components/DataTable/DataTable.vue +1415 -0
- package/src/components/DataTable/index.ts +10 -0
- package/src/components/DatePicker/DatePicker.test.ts +625 -0
- package/src/components/DatePicker/DatePicker.vue +1586 -0
- package/src/components/DatePicker/index.ts +2 -0
- package/src/components/Drawer/Drawer.test.ts +336 -0
- package/src/components/Drawer/Drawer.vue +466 -0
- package/src/components/Drawer/index.ts +2 -0
- package/src/components/Dropdown/Dropdown.test.ts +607 -0
- package/src/components/Dropdown/Dropdown.vue +807 -0
- package/src/components/Dropdown/DropdownItem.vue +227 -0
- package/src/components/Dropdown/DropdownSeparator.vue +14 -0
- package/src/components/Dropdown/DropdownSub.vue +104 -0
- package/src/components/Dropdown/DropdownSubContent.vue +187 -0
- package/src/components/Dropdown/DropdownSubTrigger.vue +151 -0
- package/src/components/Dropdown/index.ts +14 -0
- package/src/components/EmptyState/EmptyState.test.ts +180 -0
- package/src/components/EmptyState/EmptyState.vue +137 -0
- package/src/components/EmptyState/index.ts +2 -0
- package/src/components/FileUpload/FileUpload.test.ts +1151 -0
- package/src/components/FileUpload/FileUpload.vue +1042 -0
- package/src/components/FileUpload/index.ts +2 -0
- package/src/components/Heading/Heading.test.ts +107 -0
- package/src/components/Heading/Heading.vue +67 -0
- package/src/components/Heading/index.ts +2 -0
- package/src/components/Icon/Icon.test.ts +157 -0
- package/src/components/Icon/Icon.vue +86 -0
- package/src/components/Icon/index.ts +2 -0
- package/src/components/Input/Input.test.ts +273 -0
- package/src/components/Input/Input.vue +388 -0
- package/src/components/Input/index.ts +2 -0
- package/src/components/Layout/Container.vue +67 -0
- package/src/components/Layout/Grid.vue +159 -0
- package/src/components/Layout/GridItem.vue +154 -0
- package/src/components/Layout/Layout.test.ts +202 -0
- package/src/components/Layout/Stack.vue +128 -0
- package/src/components/Layout/index.ts +9 -0
- package/src/components/Layout/keys.ts +7 -0
- package/src/components/Modal/Modal.test.ts +311 -0
- package/src/components/Modal/Modal.vue +336 -0
- package/src/components/Modal/index.ts +2 -0
- package/src/components/Pagination/Pagination.test.ts +303 -0
- package/src/components/Pagination/Pagination.vue +212 -0
- package/src/components/Pagination/index.ts +3 -0
- package/src/components/Pagination/utils.ts +86 -0
- package/src/components/Popover/Popover.test.ts +285 -0
- package/src/components/Popover/Popover.vue +441 -0
- package/src/components/Popover/index.ts +2 -0
- package/src/components/Progress/Progress.test.ts +361 -0
- package/src/components/Progress/Progress.vue +363 -0
- package/src/components/Progress/index.ts +7 -0
- package/src/components/Radio/Radio.test.ts +216 -0
- package/src/components/Radio/Radio.vue +214 -0
- package/src/components/Radio/index.ts +2 -0
- package/src/components/Rating/Rating.test.ts +319 -0
- package/src/components/Rating/Rating.vue +247 -0
- package/src/components/Rating/index.ts +2 -0
- package/src/components/SegmentedControl/SegmentedControl.test.ts +292 -0
- package/src/components/SegmentedControl/SegmentedControl.vue +288 -0
- package/src/components/SegmentedControl/index.ts +2 -0
- package/src/components/Select/Select.test.ts +589 -0
- package/src/components/Select/Select.vue +666 -0
- package/src/components/Select/index.ts +2 -0
- package/src/components/Sidebar/Sidebar.test.ts +301 -0
- package/src/components/Sidebar/SidebarGroup.vue +103 -0
- package/src/components/Sidebar/SidebarItem.vue +196 -0
- package/src/components/Sidebar/SidebarLayout.vue +42 -0
- package/src/components/Sidebar/SidebarRoot.vue +122 -0
- package/src/components/Sidebar/index.ts +11 -0
- package/src/components/Sidebar/keys.ts +14 -0
- package/src/components/Skeleton/Skeleton.test.ts +130 -0
- package/src/components/Skeleton/Skeleton.vue +104 -0
- package/src/components/Skeleton/index.ts +2 -0
- package/src/components/Slider/Slider.test.ts +416 -0
- package/src/components/Slider/Slider.vue +435 -0
- package/src/components/Slider/index.ts +2 -0
- package/src/components/Slider/utils.ts +91 -0
- package/src/components/Spinner/Spinner.test.ts +79 -0
- package/src/components/Spinner/Spinner.vue +159 -0
- package/src/components/Spinner/index.ts +2 -0
- package/src/components/SpireProvider/SpireProvider.vue +71 -0
- package/src/components/SpireProvider/index.ts +11 -0
- package/src/components/Stepper/Stepper.test.ts +221 -0
- package/src/components/Stepper/StepperContent.vue +51 -0
- package/src/components/Stepper/StepperItem.vue +89 -0
- package/src/components/Stepper/StepperRoot.vue +101 -0
- package/src/components/Stepper/StepperSeparator.vue +52 -0
- package/src/components/Stepper/StepperTrigger.vue +144 -0
- package/src/components/Stepper/index.ts +11 -0
- package/src/components/Stepper/keys.ts +27 -0
- package/src/components/Switch/Switch.test.ts +214 -0
- package/src/components/Switch/Switch.vue +235 -0
- package/src/components/Switch/index.ts +2 -0
- package/src/components/Tabs/Tabs.test.ts +363 -0
- package/src/components/Tabs/Tabs.vue +318 -0
- package/src/components/Tabs/index.ts +2 -0
- package/src/components/Text/Text.test.ts +154 -0
- package/src/components/Text/Text.vue +100 -0
- package/src/components/Text/index.ts +2 -0
- package/src/components/Textarea/Textarea.test.ts +432 -0
- package/src/components/Textarea/Textarea.vue +411 -0
- package/src/components/Textarea/index.ts +2 -0
- package/src/components/TimePicker/TimePicker.test.ts +352 -0
- package/src/components/TimePicker/TimePicker.vue +569 -0
- package/src/components/TimePicker/index.ts +2 -0
- package/src/components/Timeline/Timeline.test.ts +193 -0
- package/src/components/Timeline/Timeline.vue +111 -0
- package/src/components/Timeline/TimelineItem.vue +167 -0
- package/src/components/Timeline/index.ts +13 -0
- package/src/components/Timeline/keys.ts +21 -0
- package/src/components/Toast/ToastItem.test.ts +289 -0
- package/src/components/Toast/ToastItem.vue +370 -0
- package/src/components/Toast/ToastProvider.test.ts +158 -0
- package/src/components/Toast/ToastProvider.vue +181 -0
- package/src/components/Toast/index.ts +83 -0
- package/src/components/Toast/toastState.test.ts +165 -0
- package/src/components/Toast/toastState.ts +161 -0
- package/src/components/ToggleButton/ToggleButton.test.ts +166 -0
- package/src/components/ToggleButton/ToggleButton.vue +197 -0
- package/src/components/ToggleButton/index.ts +2 -0
- package/src/components/ToggleGroup/ToggleGroup.test.ts +181 -0
- package/src/components/ToggleGroup/ToggleGroup.vue +130 -0
- package/src/components/ToggleGroup/index.ts +2 -0
- package/src/components/Tooltip/Tooltip.test.ts +238 -0
- package/src/components/Tooltip/Tooltip.vue +217 -0
- package/src/components/Tooltip/index.ts +2 -0
- package/src/components/TreeView/TreeView.test.ts +357 -0
- package/src/components/TreeView/TreeView.vue +251 -0
- package/src/components/TreeView/TreeViewItem.vue +288 -0
- package/src/components/TreeView/index.ts +11 -0
- package/src/components/TreeView/keys.ts +35 -0
- package/src/composables/index.ts +12 -0
- package/src/composables/useClickOutside.ts +36 -0
- package/src/composables/useClipboard.ts +35 -0
- package/src/composables/useEventListener.ts +48 -0
- package/src/composables/useFocusTrap.ts +58 -0
- package/src/composables/useHoverReveal.ts +98 -0
- package/src/composables/useId.ts +10 -0
- package/src/composables/useMagnetic.ts +171 -0
- package/src/composables/useRelativePosition.ts +127 -0
- package/src/composables/useRipple.ts +146 -0
- package/src/composables/useScrollLock.ts +25 -0
- package/src/composables/useSpireConfig.ts +27 -0
- package/src/composables/useStagger.ts +224 -0
- package/src/config/icons.test.ts +115 -0
- package/src/config/icons.ts +170 -0
- package/src/index.ts +361 -0
- package/src/styles/depth.css +129 -0
- package/src/styles/effects.css +169 -0
- package/src/styles/fallback.css +152 -0
- package/src/styles/main.css +25 -0
- package/src/styles/mood.css +211 -0
- package/src/styles/motion.css +159 -0
- package/src/styles/reset.css +97 -0
- package/src/styles/theme.css +708 -0
- package/src/styles/tokens.css +183 -0
- package/src/utils/.gitkeep +0 -0
- package/src/utils/color.ts +277 -0
- package/src/utils/date.test.ts +522 -0
- package/src/utils/date.ts +380 -0
- package/src/utils/index.ts +23 -0
- package/src/utils/object.test.ts +80 -0
- package/src/utils/object.ts +25 -0
- package/src/utils/string.test.ts +64 -0
- package/src/utils/string.ts +32 -0
- 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
|
+
}
|