@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,1586 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
3
+ import Popover from '../Popover/Popover.vue'
4
+ import Button from '../Button/Button.vue'
5
+ import Icon from '../Icon/Icon.vue'
6
+ import { useId } from '../../composables'
7
+ import { useInternalIcon } from '../../config/icons'
8
+ import {
9
+ generateCalendarGrid,
10
+ formatDate,
11
+ parseDate,
12
+ isSameDate,
13
+ isDateInRange,
14
+ getMonthName,
15
+ getMonthNamesShort,
16
+ getWeekdayNames,
17
+ getRangeClass,
18
+ normalizeRange,
19
+ generateYearGrid,
20
+ type CalendarDay,
21
+ type RangeState
22
+ } from '../../utils'
23
+
24
+ export type ViewMode = 'day' | 'month' | 'year'
25
+
26
+ export interface DatePickerProps {
27
+ /** Selected date in ISO format (single mode) or [start, end] tuple (range mode) */
28
+ modelValue?: string | [string, string]
29
+ /** Selection mode */
30
+ mode?: 'single' | 'range'
31
+ /** Minimum selectable date in ISO format */
32
+ min?: string
33
+ /** Maximum selectable date in ISO format */
34
+ max?: string
35
+ /** Label text above the input */
36
+ label?: string
37
+ /** Placeholder text when no date is selected */
38
+ placeholder?: string
39
+ /** Helper text below input */
40
+ hint?: string
41
+ /** Error message (also sets error state) */
42
+ error?: string
43
+ /** Input size - matches Button/Input heights */
44
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
45
+ /** Disabled state */
46
+ disabled?: boolean
47
+ /** Required field indicator */
48
+ required?: boolean
49
+ /** HTML id attribute */
50
+ id?: string
51
+ /** HTML name attribute */
52
+ name?: string
53
+ /** Make input full width */
54
+ block?: boolean
55
+ /** Locale for month/day names (defaults to browser locale) */
56
+ locale?: string
57
+ /** Format function for displaying the selected date */
58
+ formatDisplay?: (date: Date) => string
59
+ /** Format function for displaying range (range mode only) */
60
+ formatRangeDisplay?: (start: Date, end: Date) => string
61
+ }
62
+
63
+ const props = withDefaults(defineProps<DatePickerProps>(), {
64
+ mode: 'single',
65
+ size: 'md',
66
+ disabled: false,
67
+ required: false,
68
+ block: false,
69
+ placeholder: 'Select date'
70
+ })
71
+
72
+ const emit = defineEmits<{
73
+ (e: 'update:modelValue', value: string | [string, string]): void
74
+ }>()
75
+
76
+ const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
77
+ const calendarRef = ref<HTMLDivElement | null>(null)
78
+ const triggerRef = ref<HTMLDivElement | null>(null)
79
+ const inputRef = ref<HTMLInputElement | null>(null)
80
+
81
+ const uid = useId('datepicker')
82
+ const inputId = computed(() => props.id || uid)
83
+ const hintId = computed(() => `${inputId.value}-hint`)
84
+ const errorId = computed(() => `${inputId.value}-error`)
85
+ const gridId = computed(() => `${inputId.value}-grid`)
86
+
87
+ const describedBy = computed(() => {
88
+ if (props.error) return errorId.value
89
+ if (props.hint) return hintId.value
90
+ return undefined
91
+ })
92
+
93
+ const calendarIcon = useInternalIcon('calendar')
94
+ const chevronLeftIcon = useInternalIcon('chevronLeft')
95
+ const chevronRightIcon = useInternalIcon('chevronRight')
96
+ const closeIcon = useInternalIcon('close')
97
+
98
+ const isMobile = ref(false)
99
+ const hasPointer = ref(false)
100
+ const isOpen = ref(false)
101
+ const inputValue = ref('')
102
+ const inputError = ref('')
103
+
104
+ function checkMobile() {
105
+ if (typeof window === 'undefined') return
106
+ isMobile.value = window.matchMedia('(max-width: 640px)').matches
107
+ hasPointer.value = window.matchMedia('(any-hover: hover)').matches
108
+ }
109
+
110
+ checkMobile()
111
+
112
+ onMounted(() => {
113
+ window.addEventListener('resize', checkMobile)
114
+ })
115
+
116
+ onUnmounted(() => {
117
+ window.removeEventListener('resize', checkMobile)
118
+ })
119
+
120
+ const viewDate = ref(new Date())
121
+ const today = new Date()
122
+ const viewMode = ref<ViewMode>('day')
123
+
124
+ const rangeState = ref<RangeState>('idle')
125
+ const rangeStart = ref<Date | null>(null)
126
+ const hoverDate = ref<Date | null>(null)
127
+
128
+ const selectedDate = computed(() => {
129
+ if (props.mode === 'range') return null
130
+ return parseDate((props.modelValue as string) || '')
131
+ })
132
+
133
+ const selectedRange = computed(() => {
134
+ if (props.mode !== 'range') return { start: null, end: null }
135
+ const val = props.modelValue as [string, string] | undefined
136
+ if (!val || !Array.isArray(val)) return { start: null, end: null }
137
+ return {
138
+ start: parseDate(val[0] || ''),
139
+ end: parseDate(val[1] || '')
140
+ }
141
+ })
142
+
143
+ const minDate = computed(() => parseDate(props.min || ''))
144
+ const maxDate = computed(() => parseDate(props.max || ''))
145
+
146
+ const viewYear = computed(() => viewDate.value.getFullYear())
147
+ const viewMonth = computed(() => viewDate.value.getMonth())
148
+ const monthName = computed(() => getMonthName(viewMonth.value, props.locale))
149
+ const weekdays = computed(() => getWeekdayNames(props.locale))
150
+ const monthNames = computed(() => getMonthNamesShort(props.locale))
151
+ const yearGrid = computed(() => generateYearGrid(viewYear.value))
152
+ const yearRangeLabel = computed(() => {
153
+ const years = yearGrid.value
154
+ return `${years[0]}–${years[years.length - 1]}`
155
+ })
156
+
157
+ const calendarGrid = computed<CalendarDay[]>(() => {
158
+ return generateCalendarGrid(viewYear.value, viewMonth.value)
159
+ })
160
+
161
+ const displayValue = computed(() => {
162
+ if (props.mode === 'range') {
163
+ const { start, end } = selectedRange.value
164
+ if (!start || !end) return ''
165
+ if (props.formatRangeDisplay) return props.formatRangeDisplay(start, end)
166
+ const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }
167
+ const startStr = start.toLocaleDateString(props.locale, opts)
168
+ const endStr = end.toLocaleDateString(props.locale, { ...opts, year: 'numeric' })
169
+ return `${startStr} – ${endStr}`
170
+ }
171
+
172
+ if (!selectedDate.value) return ''
173
+ if (props.formatDisplay) return props.formatDisplay(selectedDate.value)
174
+ return selectedDate.value.toLocaleDateString(props.locale, {
175
+ year: 'numeric',
176
+ month: 'short',
177
+ day: 'numeric'
178
+ })
179
+ })
180
+
181
+ const hasValue = computed(() => {
182
+ if (props.mode === 'range') {
183
+ return selectedRange.value.start !== null && selectedRange.value.end !== null
184
+ }
185
+ return selectedDate.value !== null
186
+ })
187
+
188
+ const canNavigatePrev = computed(() => {
189
+ if (viewMode.value === 'year') return true
190
+ if (viewMode.value === 'month') {
191
+ if (!minDate.value) return true
192
+ return viewYear.value > minDate.value.getFullYear()
193
+ }
194
+ if (!minDate.value) return true
195
+ const firstOfViewMonth = new Date(viewYear.value, viewMonth.value, 1)
196
+ const firstOfMinMonth = new Date(minDate.value.getFullYear(), minDate.value.getMonth(), 1)
197
+ return firstOfViewMonth > firstOfMinMonth
198
+ })
199
+
200
+ const canNavigateNext = computed(() => {
201
+ if (viewMode.value === 'year') return true
202
+ if (viewMode.value === 'month') {
203
+ if (!maxDate.value) return true
204
+ return viewYear.value < maxDate.value.getFullYear()
205
+ }
206
+ if (!maxDate.value) return true
207
+ const firstOfViewMonth = new Date(viewYear.value, viewMonth.value, 1)
208
+ const firstOfMaxMonth = new Date(maxDate.value.getFullYear(), maxDate.value.getMonth(), 1)
209
+ return firstOfViewMonth < firstOfMaxMonth
210
+ })
211
+
212
+ watch(
213
+ () => props.modelValue,
214
+ (newVal) => {
215
+ if (props.mode === 'range') {
216
+ const val = newVal as [string, string] | undefined
217
+ if (val && Array.isArray(val) && val[0]) {
218
+ const parsed = parseDate(val[0])
219
+ if (parsed) viewDate.value = new Date(parsed)
220
+ }
221
+ } else {
222
+ const parsed = parseDate((newVal as string) || '')
223
+ if (parsed) viewDate.value = new Date(parsed)
224
+ }
225
+ },
226
+ { immediate: true }
227
+ )
228
+
229
+ function navigateMonth(delta: number) {
230
+ const newDate = new Date(viewDate.value)
231
+ newDate.setMonth(newDate.getMonth() + delta)
232
+ viewDate.value = newDate
233
+ }
234
+
235
+ function navigateYear(delta: number) {
236
+ const newDate = new Date(viewDate.value)
237
+ newDate.setFullYear(newDate.getFullYear() + delta)
238
+ viewDate.value = newDate
239
+ }
240
+
241
+ function navigateYearGrid(delta: number) {
242
+ const newDate = new Date(viewDate.value)
243
+ newDate.setFullYear(newDate.getFullYear() + delta * 20)
244
+ viewDate.value = newDate
245
+ }
246
+
247
+ function prevPeriod() {
248
+ if (!canNavigatePrev.value) return
249
+ if (viewMode.value === 'year') navigateYearGrid(-1)
250
+ else if (viewMode.value === 'month') navigateYear(-1)
251
+ else navigateMonth(-1)
252
+ }
253
+
254
+ function nextPeriod() {
255
+ if (!canNavigateNext.value) return
256
+ if (viewMode.value === 'year') navigateYearGrid(1)
257
+ else if (viewMode.value === 'month') navigateYear(1)
258
+ else navigateMonth(1)
259
+ }
260
+
261
+ function toggleViewMode() {
262
+ if (viewMode.value === 'day') {
263
+ viewMode.value = 'year'
264
+ } else if (viewMode.value === 'year') {
265
+ viewMode.value = 'day'
266
+ } else {
267
+ viewMode.value = 'day'
268
+ }
269
+ }
270
+
271
+ function selectYear(year: number) {
272
+ viewDate.value = new Date(year, viewMonth.value, 1)
273
+ viewMode.value = 'month'
274
+ }
275
+
276
+ function selectMonth(monthIndex: number) {
277
+ viewDate.value = new Date(viewYear.value, monthIndex, 1)
278
+ viewMode.value = 'day'
279
+ }
280
+
281
+ function isYearDisabled(year: number): boolean {
282
+ if (minDate.value && year < minDate.value.getFullYear()) return true
283
+ if (maxDate.value && year > maxDate.value.getFullYear()) return true
284
+ return false
285
+ }
286
+
287
+ function isMonthDisabled(monthIndex: number): boolean {
288
+ if (minDate.value) {
289
+ if (viewYear.value < minDate.value.getFullYear()) return true
290
+ if (viewYear.value === minDate.value.getFullYear() && monthIndex < minDate.value.getMonth()) return true
291
+ }
292
+ if (maxDate.value) {
293
+ if (viewYear.value > maxDate.value.getFullYear()) return true
294
+ if (viewYear.value === maxDate.value.getFullYear() && monthIndex > maxDate.value.getMonth()) return true
295
+ }
296
+ return false
297
+ }
298
+
299
+ function selectDate(day: CalendarDay) {
300
+ if (!isDateInRange(day.date, minDate.value, maxDate.value)) return
301
+
302
+ if (props.mode === 'range') {
303
+ if (rangeState.value === 'idle') {
304
+ rangeState.value = 'selecting'
305
+ rangeStart.value = day.date
306
+ } else {
307
+ const [start, end] = normalizeRange(rangeStart.value!, day.date)
308
+ emit('update:modelValue', [formatDate(start), formatDate(end)])
309
+ rangeState.value = 'idle'
310
+ rangeStart.value = null
311
+ hoverDate.value = null
312
+ closeCalendar()
313
+ }
314
+ } else {
315
+ emit('update:modelValue', formatDate(day.date))
316
+ closeCalendar()
317
+ }
318
+ }
319
+
320
+ function applyPreset(startDate: Date, endDate: Date) {
321
+ if (props.mode === 'range') {
322
+ const [start, end] = normalizeRange(startDate, endDate)
323
+ emit('update:modelValue', [formatDate(start), formatDate(end)])
324
+ closeCalendar()
325
+ }
326
+ }
327
+
328
+ function handleDayHover(day: CalendarDay) {
329
+ if (props.mode === 'range' && rangeState.value === 'selecting') {
330
+ hoverDate.value = day.date
331
+ }
332
+ }
333
+
334
+ function handleDayLeave() {
335
+ if (props.mode === 'range') {
336
+ hoverDate.value = null
337
+ }
338
+ }
339
+
340
+ function isDayDisabled(day: CalendarDay): boolean {
341
+ return !isDateInRange(day.date, minDate.value, maxDate.value)
342
+ }
343
+
344
+ function isDaySelected(day: CalendarDay): boolean {
345
+ if (props.mode === 'range') {
346
+ const { start, end } = selectedRange.value
347
+ return isSameDate(day.date, start) || isSameDate(day.date, end)
348
+ }
349
+ return isSameDate(day.date, selectedDate.value)
350
+ }
351
+
352
+ function isDayToday(day: CalendarDay): boolean {
353
+ return isSameDate(day.date, today)
354
+ }
355
+
356
+ function getDayRangeClass(day: CalendarDay): string | null {
357
+ if (props.mode !== 'range') return null
358
+
359
+ if (rangeState.value === 'selecting' && rangeStart.value) {
360
+ return getRangeClass(day.date, rangeStart.value, null, hoverDate.value)
361
+ }
362
+
363
+ const { start, end } = selectedRange.value
364
+ return getRangeClass(day.date, start, end, null)
365
+ }
366
+
367
+ function handleKeydown(event: KeyboardEvent) {
368
+ if (!calendarRef.value || viewMode.value !== 'day') return
369
+
370
+ const buttons = Array.from(
371
+ calendarRef.value.querySelectorAll<HTMLButtonElement>('.ui-datepicker__day:not([disabled])')
372
+ )
373
+ const currentIndex = buttons.findIndex((btn) => btn === document.activeElement)
374
+ if (currentIndex === -1) return
375
+
376
+ let targetIndex = -1
377
+
378
+ switch (event.key) {
379
+ case 'ArrowLeft':
380
+ event.preventDefault()
381
+ targetIndex = currentIndex > 0 ? currentIndex - 1 : buttons.length - 1
382
+ break
383
+ case 'ArrowRight':
384
+ event.preventDefault()
385
+ targetIndex = currentIndex < buttons.length - 1 ? currentIndex + 1 : 0
386
+ break
387
+ case 'ArrowUp':
388
+ event.preventDefault()
389
+ targetIndex = currentIndex >= 7 ? currentIndex - 7 : currentIndex + 35
390
+ while (targetIndex >= buttons.length) targetIndex -= 7
391
+ break
392
+ case 'ArrowDown':
393
+ event.preventDefault()
394
+ targetIndex = currentIndex + 7 < buttons.length ? currentIndex + 7 : currentIndex - 35
395
+ while (targetIndex < 0) targetIndex += 7
396
+ break
397
+ case 'Home':
398
+ event.preventDefault()
399
+ targetIndex = 0
400
+ break
401
+ case 'End':
402
+ event.preventDefault()
403
+ targetIndex = buttons.length - 1
404
+ break
405
+ case 'Escape':
406
+ event.preventDefault()
407
+ closeCalendar()
408
+ return
409
+ }
410
+
411
+ if (targetIndex >= 0 && targetIndex < buttons.length) {
412
+ buttons[targetIndex].focus()
413
+ }
414
+ }
415
+
416
+ function openCalendar() {
417
+ if (props.disabled) return
418
+
419
+ viewMode.value = 'day'
420
+ rangeState.value = 'idle'
421
+ rangeStart.value = null
422
+ hoverDate.value = null
423
+ inputError.value = ''
424
+
425
+ if (props.mode === 'range') {
426
+ const { start } = selectedRange.value
427
+ viewDate.value = start ? new Date(start) : new Date()
428
+ inputValue.value = ''
429
+ } else {
430
+ viewDate.value = selectedDate.value ? new Date(selectedDate.value) : new Date()
431
+ inputValue.value = selectedDate.value ? formatDate(selectedDate.value) : ''
432
+ }
433
+
434
+ if (isMobile.value) {
435
+ isOpen.value = true
436
+ document.body.style.overflow = 'hidden'
437
+ nextTick(() => focusCalendar())
438
+ } else {
439
+ popoverRef.value?.open()
440
+ }
441
+ }
442
+
443
+ function closeCalendar() {
444
+ viewMode.value = 'day'
445
+ rangeState.value = 'idle'
446
+ rangeStart.value = null
447
+ hoverDate.value = null
448
+ inputError.value = ''
449
+
450
+ if (isMobile.value) {
451
+ isOpen.value = false
452
+ document.body.style.overflow = ''
453
+ triggerRef.value?.focus()
454
+ } else {
455
+ popoverRef.value?.close()
456
+ }
457
+ }
458
+
459
+ function handlePopoverOpen() {
460
+ nextTick(() => focusCalendar())
461
+ }
462
+
463
+ function handlePopoverClose() {
464
+ viewMode.value = 'day'
465
+ rangeState.value = 'idle'
466
+ rangeStart.value = null
467
+ hoverDate.value = null
468
+ inputError.value = ''
469
+ }
470
+
471
+ function focusCalendar() {
472
+ if (viewMode.value !== 'day') return
473
+
474
+ const selectedButton = calendarRef.value?.querySelector<HTMLButtonElement>(
475
+ '.ui-datepicker__day--selected, .ui-datepicker__day--range-start'
476
+ )
477
+ const todayButton = calendarRef.value?.querySelector<HTMLButtonElement>(
478
+ '.ui-datepicker__day--today'
479
+ )
480
+ const firstButton = calendarRef.value?.querySelector<HTMLButtonElement>(
481
+ '.ui-datepicker__day:not([disabled])'
482
+ )
483
+ ;(selectedButton || todayButton || firstButton)?.focus()
484
+ }
485
+
486
+ function handleOverlayClick(event: MouseEvent) {
487
+ if ((event.target as HTMLElement).classList.contains('ui-datepicker-sheet__overlay')) {
488
+ closeCalendar()
489
+ }
490
+ }
491
+
492
+ function handleTriggerClick() {
493
+ if (isOpen.value) {
494
+ closeCalendar()
495
+ } else {
496
+ openCalendar()
497
+ }
498
+ }
499
+
500
+ function handleTriggerKeydown(event: KeyboardEvent) {
501
+ if (event.key === 'Enter' || event.key === ' ') {
502
+ event.preventDefault()
503
+ openCalendar()
504
+ }
505
+ }
506
+
507
+ function handleInputFocus() {
508
+ if (!isOpen.value) {
509
+ openCalendar()
510
+ }
511
+ }
512
+
513
+ function handleInputChange(event: Event) {
514
+ const target = event.target as HTMLInputElement
515
+ inputValue.value = target.value
516
+ }
517
+
518
+ function handleInputBlur(event: FocusEvent) {
519
+ const relatedTarget = event.relatedTarget as HTMLElement | null
520
+ const popoverContent = calendarRef.value?.closest('.ui-popover__content')
521
+ if (popoverContent && relatedTarget && popoverContent.contains(relatedTarget)) {
522
+ return
523
+ }
524
+ validateAndApplyInput(false)
525
+ }
526
+
527
+ function handleInputKeydown(event: KeyboardEvent) {
528
+ if (event.key === 'Enter') {
529
+ event.preventDefault()
530
+ validateAndApplyInput(true)
531
+ } else if (event.key === 'Escape') {
532
+ event.preventDefault()
533
+ closeCalendar()
534
+ }
535
+ }
536
+
537
+ function validateAndApplyInput(shouldClose: boolean) {
538
+ if (!inputValue.value) {
539
+ inputError.value = ''
540
+ return
541
+ }
542
+
543
+ const parsed = parseDate(inputValue.value)
544
+ if (!parsed) {
545
+ inputError.value = 'Invalid date format (use YYYY-MM-DD)'
546
+ return
547
+ }
548
+
549
+ if (!isDateInRange(parsed, minDate.value, maxDate.value)) {
550
+ inputError.value = 'Date is outside allowed range'
551
+ return
552
+ }
553
+
554
+ inputError.value = ''
555
+ emit('update:modelValue', formatDate(parsed))
556
+ viewDate.value = new Date(parsed)
557
+ if (shouldClose) {
558
+ closeCalendar()
559
+ }
560
+ }
561
+
562
+ const iconSizeMap: Record<string, string> = {
563
+ xs: 'var(--text-xs)',
564
+ sm: 'var(--text-sm)',
565
+ md: 'var(--text-md)',
566
+ lg: 'var(--text-lg)',
567
+ xl: 'var(--text-xl)'
568
+ }
569
+ const iconSize = computed(() => iconSizeMap[props.size])
570
+
571
+ function getFocusedDayIndex(): number {
572
+ if (props.mode === 'range') {
573
+ const { start } = selectedRange.value
574
+ if (start) {
575
+ const idx = calendarGrid.value.findIndex((d) => isSameDate(d.date, start))
576
+ if (idx !== -1) return idx
577
+ }
578
+ } else if (selectedDate.value) {
579
+ const idx = calendarGrid.value.findIndex((d) => isSameDate(d.date, selectedDate.value))
580
+ if (idx !== -1) return idx
581
+ }
582
+
583
+ const todayIdx = calendarGrid.value.findIndex((d) => isSameDate(d.date, today))
584
+ if (todayIdx !== -1) return todayIdx
585
+
586
+ return calendarGrid.value.findIndex((d) => d.currentMonth)
587
+ }
588
+
589
+ const headerTitle = computed(() => {
590
+ if (viewMode.value === 'year') return yearRangeLabel.value
591
+ if (viewMode.value === 'month') return String(viewYear.value)
592
+ return `${monthName.value} ${viewYear.value}`
593
+ })
594
+
595
+ defineExpose({
596
+ open: openCalendar,
597
+ close: closeCalendar,
598
+ toggle: () => (isOpen.value || popoverRef.value ? closeCalendar() : openCalendar()),
599
+ applyPreset
600
+ })
601
+ </script>
602
+
603
+ <template>
604
+ <div
605
+ class="ui-datepicker-field"
606
+ :class="[
607
+ `ui-datepicker-field--${size}`,
608
+ {
609
+ 'ui-datepicker-field--block': block,
610
+ 'ui-datepicker-field--disabled': disabled,
611
+ 'ui-datepicker-field--error': error || inputError
612
+ }
613
+ ]"
614
+ >
615
+ <label v-if="label" :for="inputId" class="ui-datepicker-field__label">
616
+ {{ label }}
617
+ <span v-if="required" class="ui-datepicker-field__required" aria-hidden="true">*</span>
618
+ </label>
619
+
620
+ <!-- Desktop: Popover -->
621
+ <Popover
622
+ v-if="!isMobile"
623
+ ref="popoverRef"
624
+ placement="bottom-start"
625
+ :disabled="disabled"
626
+ :width="mode === 'range' ? 'auto' : 308"
627
+ :close-on-escape="true"
628
+ :close-on-click-outside="true"
629
+ :trap-focus="true"
630
+ @open="handlePopoverOpen"
631
+ @close="handlePopoverClose"
632
+ >
633
+ <template #trigger>
634
+ <div
635
+ ref="triggerRef"
636
+ class="ui-datepicker-trigger"
637
+ :class="[
638
+ `ui-datepicker-trigger--${size}`,
639
+ {
640
+ 'ui-datepicker-trigger--disabled': disabled,
641
+ 'ui-datepicker-trigger--error': error || inputError,
642
+ 'ui-datepicker-trigger--has-value': hasValue
643
+ }
644
+ ]"
645
+ >
646
+ <!-- Editable input on desktop (single mode only) -->
647
+ <input
648
+ v-if="hasPointer && mode === 'single'"
649
+ ref="inputRef"
650
+ :id="inputId"
651
+ type="text"
652
+ class="ui-datepicker-trigger__input"
653
+ :value="inputValue || displayValue"
654
+ :placeholder="placeholder"
655
+ :disabled="disabled"
656
+ :aria-describedby="describedBy"
657
+ :aria-invalid="(error || inputError) ? 'true' : undefined"
658
+ autocomplete="off"
659
+ @click.stop
660
+ @focus="handleInputFocus"
661
+ @input="handleInputChange"
662
+ @blur="handleInputBlur"
663
+ @keydown="handleInputKeydown"
664
+ />
665
+ <!-- Non-editable trigger for touch or range mode -->
666
+ <div
667
+ v-else
668
+ :id="inputId"
669
+ role="combobox"
670
+ aria-haspopup="grid"
671
+ :aria-expanded="false"
672
+ :aria-controls="gridId"
673
+ :aria-describedby="describedBy"
674
+ :aria-invalid="(error || inputError) ? 'true' : undefined"
675
+ :aria-disabled="disabled"
676
+ tabindex="0"
677
+ class="ui-datepicker-trigger__value-wrapper"
678
+ @click.stop="openCalendar"
679
+ @keydown="handleTriggerKeydown"
680
+ >
681
+ <span class="ui-datepicker-trigger__value">
682
+ {{ displayValue || placeholder }}
683
+ </span>
684
+ </div>
685
+ <Icon :icon="calendarIcon" :size="iconSize" class="ui-datepicker-trigger__icon" @click.stop="openCalendar" />
686
+ <input
687
+ v-if="mode === 'single'"
688
+ type="hidden"
689
+ :name="name"
690
+ :value="modelValue"
691
+ :required="required"
692
+ />
693
+ <template v-else-if="name">
694
+ <input
695
+ type="hidden"
696
+ :name="`${name}[0]`"
697
+ :value="(modelValue as [string, string])?.[0] || ''"
698
+ />
699
+ <input
700
+ type="hidden"
701
+ :name="`${name}[1]`"
702
+ :value="(modelValue as [string, string])?.[1] || ''"
703
+ />
704
+ </template>
705
+ </div>
706
+ </template>
707
+
708
+ <template #default>
709
+ <div class="ui-datepicker-content" :class="{ 'ui-datepicker-content--with-sidebar': mode === 'range' && $slots.sidebar }">
710
+ <!-- Sidebar slot for presets -->
711
+ <div v-if="mode === 'range' && $slots.sidebar" class="ui-datepicker-sidebar">
712
+ <slot name="sidebar" :apply-preset="applyPreset" />
713
+ </div>
714
+
715
+ <div class="ui-datepicker" role="application" aria-label="Date picker">
716
+ <div class="ui-datepicker__header">
717
+ <Button
718
+ variant="ghost"
719
+ size="sm"
720
+ :icon-left="chevronLeftIcon"
721
+ :disabled="!canNavigatePrev"
722
+ aria-label="Previous"
723
+ @click="prevPeriod"
724
+ />
725
+ <button
726
+ type="button"
727
+ class="ui-datepicker__title-btn"
728
+ aria-live="polite"
729
+ @click="toggleViewMode"
730
+ >
731
+ {{ headerTitle }}
732
+ </button>
733
+ <Button
734
+ variant="ghost"
735
+ size="sm"
736
+ :icon-left="chevronRightIcon"
737
+ :disabled="!canNavigateNext"
738
+ aria-label="Next"
739
+ @click="nextPeriod"
740
+ />
741
+ </div>
742
+
743
+ <!-- Year Grid -->
744
+ <div v-if="viewMode === 'year'" class="ui-datepicker__year-grid">
745
+ <button
746
+ v-for="year in yearGrid"
747
+ :key="year"
748
+ type="button"
749
+ class="ui-datepicker__year"
750
+ :class="{
751
+ 'ui-datepicker__year--selected': year === viewYear,
752
+ 'ui-datepicker__year--current': year === today.getFullYear()
753
+ }"
754
+ :disabled="isYearDisabled(year)"
755
+ @click="selectYear(year)"
756
+ >
757
+ {{ year }}
758
+ </button>
759
+ </div>
760
+
761
+ <!-- Month Grid -->
762
+ <div v-else-if="viewMode === 'month'" class="ui-datepicker__month-grid">
763
+ <button
764
+ v-for="(month, index) in monthNames"
765
+ :key="index"
766
+ type="button"
767
+ class="ui-datepicker__month"
768
+ :class="{
769
+ 'ui-datepicker__month--selected': index === viewMonth,
770
+ 'ui-datepicker__month--current': index === today.getMonth() && viewYear === today.getFullYear()
771
+ }"
772
+ :disabled="isMonthDisabled(index)"
773
+ @click="selectMonth(index)"
774
+ >
775
+ {{ month }}
776
+ </button>
777
+ </div>
778
+
779
+ <!-- Day Grid -->
780
+ <template v-else>
781
+ <div class="ui-datepicker__weekdays" role="row">
782
+ <span
783
+ v-for="day in weekdays"
784
+ :key="day"
785
+ class="ui-datepicker__weekday"
786
+ role="columnheader"
787
+ :abbr="day"
788
+ >
789
+ {{ day }}
790
+ </span>
791
+ </div>
792
+
793
+ <div
794
+ ref="calendarRef"
795
+ :id="gridId"
796
+ class="ui-datepicker__grid"
797
+ role="grid"
798
+ :aria-label="`${monthName} ${viewYear}`"
799
+ @keydown="handleKeydown"
800
+ >
801
+ <button
802
+ v-for="(day, index) in calendarGrid"
803
+ :key="index"
804
+ type="button"
805
+ class="ui-datepicker__day"
806
+ :class="[
807
+ {
808
+ 'ui-datepicker__day--other-month': !day.currentMonth,
809
+ 'ui-datepicker__day--selected': isDaySelected(day),
810
+ 'ui-datepicker__day--today': isDayToday(day),
811
+ 'ui-datepicker__day--disabled': isDayDisabled(day)
812
+ },
813
+ getDayRangeClass(day) ? `ui-datepicker__day--range-${getDayRangeClass(day)}` : ''
814
+ ]"
815
+ role="gridcell"
816
+ :disabled="isDayDisabled(day)"
817
+ :aria-selected="isDaySelected(day) || undefined"
818
+ :aria-current="isDayToday(day) ? 'date' : undefined"
819
+ :aria-disabled="isDayDisabled(day) || undefined"
820
+ :tabindex="index === getFocusedDayIndex() ? 0 : -1"
821
+ @click="selectDate(day)"
822
+ @mouseenter="handleDayHover(day)"
823
+ @mouseleave="handleDayLeave"
824
+ >
825
+ {{ day.day }}
826
+ </button>
827
+ </div>
828
+
829
+ <div v-if="mode === 'range' && rangeState === 'selecting'" class="ui-datepicker__hint">
830
+ Select end date
831
+ </div>
832
+ </template>
833
+ </div>
834
+ </div>
835
+ </template>
836
+ </Popover>
837
+
838
+ <!-- Mobile: Trigger only (sheet is separate) -->
839
+ <div
840
+ v-else
841
+ ref="triggerRef"
842
+ :id="inputId"
843
+ class="ui-datepicker-trigger"
844
+ :class="[
845
+ `ui-datepicker-trigger--${size}`,
846
+ {
847
+ 'ui-datepicker-trigger--disabled': disabled,
848
+ 'ui-datepicker-trigger--error': error,
849
+ 'ui-datepicker-trigger--has-value': hasValue
850
+ }
851
+ ]"
852
+ role="combobox"
853
+ aria-haspopup="dialog"
854
+ :aria-expanded="isOpen"
855
+ :aria-controls="gridId"
856
+ :aria-describedby="describedBy"
857
+ :aria-invalid="error ? 'true' : undefined"
858
+ :aria-disabled="disabled"
859
+ tabindex="0"
860
+ @click="handleTriggerClick"
861
+ @keydown="handleTriggerKeydown"
862
+ >
863
+ <span class="ui-datepicker-trigger__value">
864
+ {{ displayValue || placeholder }}
865
+ </span>
866
+ <Icon :icon="calendarIcon" :size="iconSize" class="ui-datepicker-trigger__icon" />
867
+ <input
868
+ v-if="mode === 'single'"
869
+ type="hidden"
870
+ :name="name"
871
+ :value="modelValue"
872
+ :required="required"
873
+ />
874
+ <template v-else-if="name">
875
+ <input
876
+ type="hidden"
877
+ :name="`${name}[0]`"
878
+ :value="(modelValue as [string, string])?.[0] || ''"
879
+ />
880
+ <input
881
+ type="hidden"
882
+ :name="`${name}[1]`"
883
+ :value="(modelValue as [string, string])?.[1] || ''"
884
+ />
885
+ </template>
886
+ </div>
887
+
888
+ <!-- Mobile: Bottom Sheet -->
889
+ <Teleport to="body">
890
+ <Transition name="ui-datepicker-sheet">
891
+ <div
892
+ v-if="isMobile && isOpen"
893
+ class="ui-datepicker-sheet__overlay"
894
+ @click="handleOverlayClick"
895
+ >
896
+ <div
897
+ class="ui-datepicker-sheet"
898
+ role="dialog"
899
+ aria-modal="true"
900
+ aria-label="Date picker"
901
+ >
902
+ <div class="ui-datepicker-sheet__header">
903
+ <span class="ui-datepicker-sheet__title">
904
+ {{ label || (mode === 'range' ? 'Select dates' : 'Select date') }}
905
+ </span>
906
+ <Button
907
+ variant="ghost"
908
+ size="sm"
909
+ :icon-left="closeIcon"
910
+ aria-label="Close"
911
+ @click="closeCalendar"
912
+ />
913
+ </div>
914
+
915
+ <!-- Mobile sidebar for range presets -->
916
+ <div v-if="mode === 'range' && $slots.sidebar" class="ui-datepicker-sheet__presets">
917
+ <slot name="sidebar" :apply-preset="applyPreset" />
918
+ </div>
919
+
920
+ <div class="ui-datepicker ui-datepicker--mobile">
921
+ <div class="ui-datepicker__header">
922
+ <Button
923
+ variant="ghost"
924
+ size="md"
925
+ :icon-left="chevronLeftIcon"
926
+ :disabled="!canNavigatePrev"
927
+ aria-label="Previous"
928
+ @click="prevPeriod"
929
+ />
930
+ <button
931
+ type="button"
932
+ class="ui-datepicker__title-btn"
933
+ aria-live="polite"
934
+ @click="toggleViewMode"
935
+ >
936
+ {{ headerTitle }}
937
+ </button>
938
+ <Button
939
+ variant="ghost"
940
+ size="md"
941
+ :icon-left="chevronRightIcon"
942
+ :disabled="!canNavigateNext"
943
+ aria-label="Next"
944
+ @click="nextPeriod"
945
+ />
946
+ </div>
947
+
948
+ <!-- Year Grid (Mobile) -->
949
+ <div v-if="viewMode === 'year'" class="ui-datepicker__year-grid ui-datepicker__year-grid--mobile">
950
+ <button
951
+ v-for="year in yearGrid"
952
+ :key="year"
953
+ type="button"
954
+ class="ui-datepicker__year"
955
+ :class="{
956
+ 'ui-datepicker__year--selected': year === viewYear,
957
+ 'ui-datepicker__year--current': year === today.getFullYear()
958
+ }"
959
+ :disabled="isYearDisabled(year)"
960
+ @click="selectYear(year)"
961
+ >
962
+ {{ year }}
963
+ </button>
964
+ </div>
965
+
966
+ <!-- Month Grid (Mobile) -->
967
+ <div v-else-if="viewMode === 'month'" class="ui-datepicker__month-grid ui-datepicker__month-grid--mobile">
968
+ <button
969
+ v-for="(month, index) in monthNames"
970
+ :key="index"
971
+ type="button"
972
+ class="ui-datepicker__month"
973
+ :class="{
974
+ 'ui-datepicker__month--selected': index === viewMonth,
975
+ 'ui-datepicker__month--current': index === today.getMonth() && viewYear === today.getFullYear()
976
+ }"
977
+ :disabled="isMonthDisabled(index)"
978
+ @click="selectMonth(index)"
979
+ >
980
+ {{ month }}
981
+ </button>
982
+ </div>
983
+
984
+ <!-- Day Grid (Mobile) -->
985
+ <template v-else>
986
+ <div class="ui-datepicker__weekdays" role="row">
987
+ <span
988
+ v-for="day in weekdays"
989
+ :key="day"
990
+ class="ui-datepicker__weekday"
991
+ role="columnheader"
992
+ :abbr="day"
993
+ >
994
+ {{ day }}
995
+ </span>
996
+ </div>
997
+
998
+ <div
999
+ ref="calendarRef"
1000
+ :id="gridId"
1001
+ class="ui-datepicker__grid"
1002
+ role="grid"
1003
+ :aria-label="`${monthName} ${viewYear}`"
1004
+ @keydown="handleKeydown"
1005
+ >
1006
+ <button
1007
+ v-for="(day, index) in calendarGrid"
1008
+ :key="index"
1009
+ type="button"
1010
+ class="ui-datepicker__day"
1011
+ :class="[
1012
+ {
1013
+ 'ui-datepicker__day--other-month': !day.currentMonth,
1014
+ 'ui-datepicker__day--selected': isDaySelected(day),
1015
+ 'ui-datepicker__day--today': isDayToday(day),
1016
+ 'ui-datepicker__day--disabled': isDayDisabled(day)
1017
+ },
1018
+ getDayRangeClass(day) ? `ui-datepicker__day--range-${getDayRangeClass(day)}` : ''
1019
+ ]"
1020
+ role="gridcell"
1021
+ :disabled="isDayDisabled(day)"
1022
+ :aria-selected="isDaySelected(day) || undefined"
1023
+ :aria-current="isDayToday(day) ? 'date' : undefined"
1024
+ :aria-disabled="isDayDisabled(day) || undefined"
1025
+ :tabindex="index === getFocusedDayIndex() ? 0 : -1"
1026
+ @click="selectDate(day)"
1027
+ >
1028
+ {{ day.day }}
1029
+ </button>
1030
+ </div>
1031
+
1032
+ <div v-if="mode === 'range' && rangeState === 'selecting'" class="ui-datepicker__hint">
1033
+ Select end date
1034
+ </div>
1035
+ </template>
1036
+ </div>
1037
+ </div>
1038
+ </div>
1039
+ </Transition>
1040
+ </Teleport>
1041
+
1042
+ <p
1043
+ v-if="error || inputError"
1044
+ :id="errorId"
1045
+ class="ui-datepicker-field__message ui-datepicker-field__message--error"
1046
+ role="alert"
1047
+ >
1048
+ {{ error || inputError }}
1049
+ </p>
1050
+ <p
1051
+ v-else-if="hint"
1052
+ :id="hintId"
1053
+ class="ui-datepicker-field__message ui-datepicker-field__message--hint"
1054
+ >
1055
+ {{ hint }}
1056
+ </p>
1057
+ </div>
1058
+ </template>
1059
+
1060
+ <style scoped>
1061
+ .ui-datepicker-field {
1062
+ display: flex;
1063
+ flex-direction: column;
1064
+ gap: var(--space-1);
1065
+ font-family: var(--font-sans);
1066
+ }
1067
+
1068
+ .ui-datepicker-field--block {
1069
+ width: 100%;
1070
+ }
1071
+
1072
+ .ui-datepicker-field__label {
1073
+ font-size: var(--text-sm);
1074
+ font-weight: var(--font-medium);
1075
+ color: var(--input-label);
1076
+ line-height: var(--leading-tight);
1077
+ }
1078
+
1079
+ .ui-datepicker-field__required {
1080
+ color: var(--input-error);
1081
+ margin-left: var(--space-1);
1082
+ }
1083
+
1084
+ .ui-datepicker-field__message {
1085
+ font-size: var(--text-xs);
1086
+ line-height: var(--leading-normal);
1087
+ margin: 0;
1088
+ }
1089
+
1090
+ .ui-datepicker-field__message--hint {
1091
+ color: var(--input-hint);
1092
+ }
1093
+
1094
+ .ui-datepicker-field__message--error {
1095
+ color: var(--input-error);
1096
+ }
1097
+
1098
+ .ui-datepicker-trigger {
1099
+ position: relative;
1100
+ display: inline-flex;
1101
+ align-items: center;
1102
+ width: 100%;
1103
+ background-color: var(--input-bg);
1104
+ border: 1px solid var(--input-border);
1105
+ cursor: pointer;
1106
+ transition:
1107
+ border-color var(--duration-fast) var(--ease-default),
1108
+ box-shadow var(--duration-fast) var(--ease-default);
1109
+ }
1110
+
1111
+ .ui-datepicker-trigger:focus-within {
1112
+ outline: none;
1113
+ border-color: var(--input-border-focus);
1114
+ box-shadow: 0 0 0 3px var(--input-ring);
1115
+ }
1116
+
1117
+ .ui-datepicker-trigger:not(.ui-datepicker-trigger--disabled):hover {
1118
+ border-color: var(--input-border-hover);
1119
+ }
1120
+
1121
+ .ui-datepicker-trigger--error {
1122
+ border-color: var(--input-border-error);
1123
+ }
1124
+
1125
+ .ui-datepicker-trigger--error:focus-within {
1126
+ border-color: var(--input-border-error);
1127
+ box-shadow: 0 0 0 3px var(--input-ring-error);
1128
+ }
1129
+
1130
+ .ui-datepicker-trigger--disabled {
1131
+ background-color: var(--input-bg-disabled);
1132
+ cursor: not-allowed;
1133
+ opacity: 0.6;
1134
+ }
1135
+
1136
+ .ui-datepicker-trigger--xs {
1137
+ height: var(--input-height-xs);
1138
+ padding: 0 var(--space-1);
1139
+ font-size: var(--text-xs);
1140
+ border-radius: var(--radius-sm);
1141
+ }
1142
+
1143
+ .ui-datepicker-trigger--sm {
1144
+ height: var(--input-height-sm);
1145
+ padding: 0 var(--space-2);
1146
+ font-size: var(--text-sm);
1147
+ border-radius: var(--radius-md);
1148
+ }
1149
+
1150
+ .ui-datepicker-trigger--md {
1151
+ height: var(--input-height-md);
1152
+ padding: 0 var(--space-3);
1153
+ font-size: var(--text-sm);
1154
+ border-radius: var(--radius-md);
1155
+ }
1156
+
1157
+ .ui-datepicker-trigger--lg {
1158
+ height: var(--input-height-lg);
1159
+ padding: 0 var(--space-3);
1160
+ font-size: var(--text-base);
1161
+ border-radius: var(--radius-md);
1162
+ }
1163
+
1164
+ .ui-datepicker-trigger--xl {
1165
+ height: var(--input-height-xl);
1166
+ padding: 0 var(--space-4);
1167
+ font-size: var(--text-base);
1168
+ border-radius: var(--radius-lg);
1169
+ }
1170
+
1171
+ .ui-datepicker-trigger__input {
1172
+ flex: 1;
1173
+ width: 100%;
1174
+ height: 100%;
1175
+ border: none;
1176
+ background: transparent;
1177
+ font-family: inherit;
1178
+ font-size: inherit;
1179
+ color: var(--input-text);
1180
+ outline: none;
1181
+ padding: 0;
1182
+ }
1183
+
1184
+ .ui-datepicker-trigger__input::placeholder {
1185
+ color: var(--input-placeholder);
1186
+ }
1187
+
1188
+ .ui-datepicker-trigger__value-wrapper {
1189
+ flex: 1;
1190
+ display: flex;
1191
+ align-items: center;
1192
+ height: 100%;
1193
+ outline: none;
1194
+ }
1195
+
1196
+ .ui-datepicker-trigger__value {
1197
+ flex: 1;
1198
+ text-align: left;
1199
+ color: var(--input-text);
1200
+ overflow: hidden;
1201
+ text-overflow: ellipsis;
1202
+ white-space: nowrap;
1203
+ }
1204
+
1205
+ .ui-datepicker-trigger:not(.ui-datepicker-trigger--has-value) .ui-datepicker-trigger__value {
1206
+ color: var(--input-placeholder);
1207
+ }
1208
+
1209
+ .ui-datepicker-trigger__icon {
1210
+ flex-shrink: 0;
1211
+ color: var(--input-icon);
1212
+ margin-left: var(--space-2);
1213
+ cursor: pointer;
1214
+ }
1215
+
1216
+ /* Content layout with optional sidebar */
1217
+ .ui-datepicker-content {
1218
+ display: flex;
1219
+ }
1220
+
1221
+ .ui-datepicker-content--with-sidebar {
1222
+ min-width: 420px;
1223
+ }
1224
+
1225
+ .ui-datepicker-sidebar {
1226
+ display: flex;
1227
+ flex-direction: column;
1228
+ gap: var(--space-1);
1229
+ padding: var(--space-2);
1230
+ border-right: 1px solid var(--border-default);
1231
+ min-width: 140px;
1232
+ }
1233
+
1234
+ /* Calendar shared styles */
1235
+ .ui-datepicker {
1236
+ display: flex;
1237
+ flex-direction: column;
1238
+ gap: var(--space-2);
1239
+ min-width: 280px;
1240
+ }
1241
+
1242
+ .ui-datepicker__header {
1243
+ display: flex;
1244
+ align-items: center;
1245
+ justify-content: space-between;
1246
+ gap: var(--space-2);
1247
+ }
1248
+
1249
+ .ui-datepicker__title-btn {
1250
+ flex: 1;
1251
+ text-align: center;
1252
+ font-weight: var(--font-semibold);
1253
+ font-size: var(--text-sm);
1254
+ color: var(--text-primary);
1255
+ background: transparent;
1256
+ border: none;
1257
+ padding: var(--space-1) var(--space-2);
1258
+ border-radius: var(--radius-md);
1259
+ cursor: pointer;
1260
+ transition: background-color var(--duration-fast) var(--ease-default);
1261
+ }
1262
+
1263
+ .ui-datepicker__title-btn:hover {
1264
+ background-color: var(--action-secondary);
1265
+ }
1266
+
1267
+ .ui-datepicker__title-btn:focus-visible {
1268
+ outline: 2px solid var(--ring-color);
1269
+ outline-offset: 2px;
1270
+ }
1271
+
1272
+ .ui-datepicker__weekdays {
1273
+ display: grid;
1274
+ grid-template-columns: repeat(7, 1fr);
1275
+ gap: var(--space-1);
1276
+ }
1277
+
1278
+ .ui-datepicker__weekday {
1279
+ display: flex;
1280
+ align-items: center;
1281
+ justify-content: center;
1282
+ height: 2rem;
1283
+ font-size: var(--text-xs);
1284
+ font-weight: var(--font-medium);
1285
+ color: var(--text-tertiary);
1286
+ text-transform: uppercase;
1287
+ }
1288
+
1289
+ .ui-datepicker__grid {
1290
+ display: grid;
1291
+ grid-template-columns: repeat(7, 1fr);
1292
+ gap: var(--space-1);
1293
+ }
1294
+
1295
+ .ui-datepicker__day {
1296
+ display: flex;
1297
+ align-items: center;
1298
+ justify-content: center;
1299
+ width: 2.25rem;
1300
+ height: 2.25rem;
1301
+ padding: 0;
1302
+ border: none;
1303
+ border-radius: var(--radius-md);
1304
+ background: transparent;
1305
+ font-family: inherit;
1306
+ font-size: var(--text-sm);
1307
+ color: var(--text-primary);
1308
+ cursor: pointer;
1309
+ transition:
1310
+ background-color var(--duration-fast) var(--ease-default),
1311
+ color var(--duration-fast) var(--ease-default);
1312
+ }
1313
+
1314
+ .ui-datepicker__day:hover:not(:disabled):not(.ui-datepicker__day--selected):not(.ui-datepicker__day--range-start):not(.ui-datepicker__day--range-end) {
1315
+ background-color: var(--action-secondary);
1316
+ }
1317
+
1318
+ .ui-datepicker__day:focus-visible {
1319
+ outline: 2px solid var(--ring-color);
1320
+ outline-offset: 2px;
1321
+ z-index: 1;
1322
+ }
1323
+
1324
+ .ui-datepicker__day--other-month {
1325
+ color: var(--text-quaternary);
1326
+ opacity: 0.5;
1327
+ }
1328
+
1329
+ .ui-datepicker__day--today:not(.ui-datepicker__day--selected):not(.ui-datepicker__day--range-start):not(.ui-datepicker__day--range-end) {
1330
+ font-weight: var(--font-semibold);
1331
+ color: var(--action-primary);
1332
+ }
1333
+
1334
+ .ui-datepicker__day--selected,
1335
+ .ui-datepicker__day--range-start,
1336
+ .ui-datepicker__day--range-end {
1337
+ background-color: var(--action-primary);
1338
+ color: var(--action-primary-text);
1339
+ font-weight: var(--font-medium);
1340
+ }
1341
+
1342
+ .ui-datepicker__day--selected:hover,
1343
+ .ui-datepicker__day--range-start:hover,
1344
+ .ui-datepicker__day--range-end:hover {
1345
+ background-color: var(--action-primary-hover);
1346
+ }
1347
+
1348
+ .ui-datepicker__day--range-in-range {
1349
+ background-color: var(--action-primary-subtle, oklch(from var(--action-primary) l c h / 0.15));
1350
+ border-radius: 0;
1351
+ }
1352
+
1353
+ .ui-datepicker__day--range-start {
1354
+ border-top-right-radius: 0;
1355
+ border-bottom-right-radius: 0;
1356
+ }
1357
+
1358
+ .ui-datepicker__day--range-end {
1359
+ border-top-left-radius: 0;
1360
+ border-bottom-left-radius: 0;
1361
+ }
1362
+
1363
+ .ui-datepicker__day--range-start.ui-datepicker__day--range-end {
1364
+ border-radius: var(--radius-md);
1365
+ }
1366
+
1367
+ .ui-datepicker__day--disabled,
1368
+ .ui-datepicker__day:disabled {
1369
+ color: var(--text-disabled);
1370
+ cursor: not-allowed;
1371
+ opacity: 0.5;
1372
+ }
1373
+
1374
+ .ui-datepicker__hint {
1375
+ text-align: center;
1376
+ font-size: var(--text-xs);
1377
+ color: var(--text-tertiary);
1378
+ padding-top: var(--space-1);
1379
+ }
1380
+
1381
+ /* Year Grid */
1382
+ .ui-datepicker__year-grid {
1383
+ display: grid;
1384
+ grid-template-columns: repeat(4, 1fr);
1385
+ gap: var(--space-1);
1386
+ }
1387
+
1388
+ .ui-datepicker__year {
1389
+ display: flex;
1390
+ align-items: center;
1391
+ justify-content: center;
1392
+ height: 2.5rem;
1393
+ padding: 0;
1394
+ border: none;
1395
+ border-radius: var(--radius-md);
1396
+ background: transparent;
1397
+ font-family: inherit;
1398
+ font-size: var(--text-sm);
1399
+ color: var(--text-primary);
1400
+ cursor: pointer;
1401
+ transition:
1402
+ background-color var(--duration-fast) var(--ease-default),
1403
+ color var(--duration-fast) var(--ease-default);
1404
+ }
1405
+
1406
+ .ui-datepicker__year:hover:not(:disabled):not(.ui-datepicker__year--selected) {
1407
+ background-color: var(--action-secondary);
1408
+ }
1409
+
1410
+ .ui-datepicker__year:focus-visible {
1411
+ outline: 2px solid var(--ring-color);
1412
+ outline-offset: 2px;
1413
+ }
1414
+
1415
+ .ui-datepicker__year--current:not(.ui-datepicker__year--selected) {
1416
+ font-weight: var(--font-semibold);
1417
+ color: var(--action-primary);
1418
+ }
1419
+
1420
+ .ui-datepicker__year--selected {
1421
+ background-color: var(--action-primary);
1422
+ color: var(--action-primary-text);
1423
+ font-weight: var(--font-medium);
1424
+ }
1425
+
1426
+ .ui-datepicker__year:disabled {
1427
+ color: var(--text-disabled);
1428
+ cursor: not-allowed;
1429
+ opacity: 0.5;
1430
+ }
1431
+
1432
+ /* Month Grid */
1433
+ .ui-datepicker__month-grid {
1434
+ display: grid;
1435
+ grid-template-columns: repeat(3, 1fr);
1436
+ gap: var(--space-1);
1437
+ }
1438
+
1439
+ .ui-datepicker__month {
1440
+ display: flex;
1441
+ align-items: center;
1442
+ justify-content: center;
1443
+ height: 2.5rem;
1444
+ padding: 0;
1445
+ border: none;
1446
+ border-radius: var(--radius-md);
1447
+ background: transparent;
1448
+ font-family: inherit;
1449
+ font-size: var(--text-sm);
1450
+ color: var(--text-primary);
1451
+ cursor: pointer;
1452
+ transition:
1453
+ background-color var(--duration-fast) var(--ease-default),
1454
+ color var(--duration-fast) var(--ease-default);
1455
+ }
1456
+
1457
+ .ui-datepicker__month:hover:not(:disabled):not(.ui-datepicker__month--selected) {
1458
+ background-color: var(--action-secondary);
1459
+ }
1460
+
1461
+ .ui-datepicker__month:focus-visible {
1462
+ outline: 2px solid var(--ring-color);
1463
+ outline-offset: 2px;
1464
+ }
1465
+
1466
+ .ui-datepicker__month--current:not(.ui-datepicker__month--selected) {
1467
+ font-weight: var(--font-semibold);
1468
+ color: var(--action-primary);
1469
+ }
1470
+
1471
+ .ui-datepicker__month--selected {
1472
+ background-color: var(--action-primary);
1473
+ color: var(--action-primary-text);
1474
+ font-weight: var(--font-medium);
1475
+ }
1476
+
1477
+ .ui-datepicker__month:disabled {
1478
+ color: var(--text-disabled);
1479
+ cursor: not-allowed;
1480
+ opacity: 0.5;
1481
+ }
1482
+
1483
+ /* Mobile sheet styles */
1484
+ .ui-datepicker-sheet__overlay {
1485
+ position: fixed;
1486
+ inset: 0;
1487
+ background-color: var(--overlay-bg, rgba(0, 0, 0, 0.5));
1488
+ z-index: var(--z-modal, 100);
1489
+ display: flex;
1490
+ align-items: flex-end;
1491
+ justify-content: center;
1492
+ }
1493
+
1494
+ .ui-datepicker-sheet {
1495
+ width: 100%;
1496
+ max-height: 85vh;
1497
+ background-color: var(--bg-primary);
1498
+ border-radius: var(--radius-xl) var(--radius-xl) 0 0;
1499
+ overflow: hidden;
1500
+ display: flex;
1501
+ flex-direction: column;
1502
+ }
1503
+
1504
+ .ui-datepicker-sheet__header {
1505
+ display: flex;
1506
+ align-items: center;
1507
+ justify-content: space-between;
1508
+ padding: var(--space-3) var(--space-4);
1509
+ border-bottom: 1px solid var(--border-default);
1510
+ }
1511
+
1512
+ .ui-datepicker-sheet__title {
1513
+ font-weight: var(--font-semibold);
1514
+ font-size: var(--text-md);
1515
+ color: var(--text-primary);
1516
+ }
1517
+
1518
+ .ui-datepicker-sheet__presets {
1519
+ display: flex;
1520
+ flex-wrap: wrap;
1521
+ gap: var(--space-2);
1522
+ padding: var(--space-3) var(--space-4);
1523
+ border-bottom: 1px solid var(--border-default);
1524
+ }
1525
+
1526
+ .ui-datepicker--mobile {
1527
+ padding: var(--space-4);
1528
+ }
1529
+
1530
+ .ui-datepicker--mobile .ui-datepicker__title-btn {
1531
+ font-size: var(--text-md);
1532
+ }
1533
+
1534
+ .ui-datepicker--mobile .ui-datepicker__day {
1535
+ width: 100%;
1536
+ height: 2.75rem;
1537
+ font-size: var(--text-md);
1538
+ }
1539
+
1540
+ .ui-datepicker--mobile .ui-datepicker__weekday {
1541
+ height: 2.5rem;
1542
+ font-size: var(--text-sm);
1543
+ }
1544
+
1545
+ .ui-datepicker__year-grid--mobile .ui-datepicker__year,
1546
+ .ui-datepicker__month-grid--mobile .ui-datepicker__month {
1547
+ height: 3rem;
1548
+ font-size: var(--text-md);
1549
+ }
1550
+
1551
+ /* Sheet transitions */
1552
+ .ui-datepicker-sheet-enter-active,
1553
+ .ui-datepicker-sheet-leave-active {
1554
+ transition: opacity var(--duration-normal) var(--ease-default);
1555
+ }
1556
+
1557
+ .ui-datepicker-sheet-enter-active .ui-datepicker-sheet,
1558
+ .ui-datepicker-sheet-leave-active .ui-datepicker-sheet {
1559
+ transition: transform var(--duration-normal) var(--ease-default);
1560
+ }
1561
+
1562
+ .ui-datepicker-sheet-enter-from,
1563
+ .ui-datepicker-sheet-leave-to {
1564
+ opacity: 0;
1565
+ }
1566
+
1567
+ .ui-datepicker-sheet-enter-from .ui-datepicker-sheet,
1568
+ .ui-datepicker-sheet-leave-to .ui-datepicker-sheet {
1569
+ transform: translateY(100%);
1570
+ }
1571
+
1572
+ /* Label size variants */
1573
+ .ui-datepicker-field--xs .ui-datepicker-field__label {
1574
+ font-size: var(--text-xs);
1575
+ }
1576
+
1577
+ .ui-datepicker-field--sm .ui-datepicker-field__label,
1578
+ .ui-datepicker-field--md .ui-datepicker-field__label {
1579
+ font-size: var(--text-sm);
1580
+ }
1581
+
1582
+ .ui-datepicker-field--lg .ui-datepicker-field__label,
1583
+ .ui-datepicker-field--xl .ui-datepicker-field__label {
1584
+ font-size: var(--text-md);
1585
+ }
1586
+ </style>