@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,569 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ ref,
4
+ computed,
5
+ watch,
6
+ nextTick,
7
+ onMounted,
8
+ onUnmounted
9
+ } from 'vue'
10
+ import Popover from '../Popover/Popover.vue'
11
+ import Input from '../Input/Input.vue'
12
+ import Icon from '../Icon/Icon.vue'
13
+ import { useId } from '../../composables'
14
+ import { useInternalIcon } from '../../config/icons'
15
+ import {
16
+ parseTime,
17
+ formatTime,
18
+ generateRange,
19
+ to12Hour,
20
+ to24Hour,
21
+ isValidTimeString,
22
+ type TimeFormat
23
+ } from '../../utils/time'
24
+
25
+ export interface TimePickerProps {
26
+ /** Selected time in HH:mm format (24h) */
27
+ modelValue?: string
28
+ /** Time display format */
29
+ format?: TimeFormat
30
+ /** Minute step interval */
31
+ minuteStep?: number
32
+ /** Label text above the input */
33
+ label?: string
34
+ /** Placeholder text */
35
+ placeholder?: string
36
+ /** Helper text below input */
37
+ hint?: string
38
+ /** Error message (also sets error state) */
39
+ error?: string
40
+ /** Input size */
41
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
42
+ /** Disabled state */
43
+ disabled?: boolean
44
+ /** Required field */
45
+ required?: boolean
46
+ /** HTML id attribute */
47
+ id?: string
48
+ /** HTML name attribute */
49
+ name?: string
50
+ /** Make input full width */
51
+ block?: boolean
52
+ /** Close popover when time is selected */
53
+ closeOnSelect?: boolean
54
+ }
55
+
56
+ const props = withDefaults(defineProps<TimePickerProps>(), {
57
+ format: '12h',
58
+ minuteStep: 1,
59
+ size: 'md',
60
+ disabled: false,
61
+ required: false,
62
+ block: false,
63
+ closeOnSelect: true
64
+ })
65
+
66
+ const emit = defineEmits<{
67
+ (e: 'update:modelValue', value: string): void
68
+ (e: 'focus', event: FocusEvent): void
69
+ (e: 'blur', event: FocusEvent): void
70
+ }>()
71
+
72
+ const uid = useId('timepicker')
73
+ const inputId = computed(() => props.id || uid)
74
+
75
+ const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
76
+ const hourColumnRef = ref<HTMLElement | null>(null)
77
+ const minuteColumnRef = ref<HTMLElement | null>(null)
78
+ const periodColumnRef = ref<HTMLElement | null>(null)
79
+
80
+ const inputValue = ref('')
81
+ const selectedHour = ref(12)
82
+ const selectedMinute = ref(0)
83
+ const selectedPeriod = ref<'AM' | 'PM'>('AM')
84
+
85
+ const isScrolling = ref(false)
86
+ const scrollTimeout = ref<number | null>(null)
87
+
88
+ const calendarIcon = useInternalIcon('calendar')
89
+
90
+ const hours = computed(() => {
91
+ if (props.format === '12h') {
92
+ return generateRange(1, 12)
93
+ }
94
+ return generateRange(0, 23)
95
+ })
96
+
97
+ const minutes = computed(() => generateRange(0, 59, props.minuteStep))
98
+
99
+ const periods: ('AM' | 'PM')[] = ['AM', 'PM']
100
+
101
+ const ITEM_HEIGHT = 40
102
+ const VISIBLE_ITEMS = 5
103
+ const CONTAINER_HEIGHT = ITEM_HEIGHT * VISIBLE_ITEMS
104
+
105
+ function initializeFromModelValue() {
106
+ if (props.modelValue && isValidTimeString(props.modelValue)) {
107
+ const parsed = parseTime(props.modelValue)
108
+ if (parsed) {
109
+ if (props.format === '12h') {
110
+ const converted = to12Hour(parsed.hours)
111
+ selectedHour.value = converted.hour
112
+ selectedPeriod.value = converted.period
113
+ } else {
114
+ selectedHour.value = parsed.hours
115
+ }
116
+ selectedMinute.value = findClosestMinute(parsed.minutes)
117
+ updateInputDisplay()
118
+ }
119
+ }
120
+ }
121
+
122
+ function findClosestMinute(minute: number): number {
123
+ const available = minutes.value
124
+ return available.reduce((prev, curr) =>
125
+ Math.abs(curr - minute) < Math.abs(prev - minute) ? curr : prev
126
+ )
127
+ }
128
+
129
+ function updateInputDisplay() {
130
+ const h24 = props.format === '12h'
131
+ ? to24Hour(selectedHour.value, selectedPeriod.value)
132
+ : selectedHour.value
133
+ inputValue.value = formatTime(h24, selectedMinute.value, props.format)
134
+ }
135
+
136
+ function emitValue() {
137
+ const h24 = props.format === '12h'
138
+ ? to24Hour(selectedHour.value, selectedPeriod.value)
139
+ : selectedHour.value
140
+ const value = formatTime(h24, selectedMinute.value, '24h')
141
+ emit('update:modelValue', value)
142
+ }
143
+
144
+ function handleInputChange(value: string | number) {
145
+ const strValue = String(value)
146
+ inputValue.value = strValue
147
+
148
+ const parsed = parseTime(strValue)
149
+ if (parsed) {
150
+ if (props.format === '12h') {
151
+ const converted = to12Hour(parsed.hours)
152
+ selectedHour.value = converted.hour
153
+ selectedPeriod.value = converted.period
154
+ } else {
155
+ selectedHour.value = parsed.hours
156
+ }
157
+ selectedMinute.value = findClosestMinute(parsed.minutes)
158
+ emitValue()
159
+ scrollToSelected()
160
+ }
161
+ }
162
+
163
+ function handleInputBlur(event: FocusEvent) {
164
+ updateInputDisplay()
165
+ emit('blur', event)
166
+ }
167
+
168
+ function handleInputFocus(event: FocusEvent) {
169
+ emit('focus', event)
170
+ }
171
+
172
+ function scrollToSelected() {
173
+ nextTick(() => {
174
+ scrollColumnToValue(hourColumnRef.value, selectedHour.value, hours.value)
175
+ scrollColumnToValue(minuteColumnRef.value, selectedMinute.value, minutes.value)
176
+ if (props.format === '12h' && periodColumnRef.value) {
177
+ scrollColumnToValue(periodColumnRef.value, selectedPeriod.value, periods)
178
+ }
179
+ })
180
+ }
181
+
182
+ function scrollColumnToValue<T>(column: HTMLElement | null, value: T, items: T[]) {
183
+ if (!column) return
184
+
185
+ const index = items.indexOf(value)
186
+ if (index === -1) return
187
+
188
+ const scrollTop = index * ITEM_HEIGHT
189
+ column.scrollTop = scrollTop
190
+ }
191
+
192
+ function setupScrollObserver(
193
+ column: HTMLElement | null,
194
+ items: readonly (number | string)[],
195
+ onSelect: (value: number | string) => void
196
+ ) {
197
+ if (!column) return () => {}
198
+
199
+ const itemElements = column.querySelectorAll<HTMLElement>('[data-value]')
200
+ if (itemElements.length === 0) return () => {}
201
+
202
+ const observer = new IntersectionObserver(
203
+ (entries) => {
204
+ if (isScrolling.value) return
205
+
206
+ for (const entry of entries) {
207
+ if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
208
+ const value = entry.target.getAttribute('data-value')
209
+ if (value !== null) {
210
+ const parsed = isNaN(Number(value)) ? value : Number(value)
211
+ onSelect(parsed)
212
+ }
213
+ }
214
+ }
215
+ },
216
+ {
217
+ root: column,
218
+ rootMargin: `-${CONTAINER_HEIGHT / 2 - ITEM_HEIGHT / 2}px 0px`,
219
+ threshold: 0.5
220
+ }
221
+ )
222
+
223
+ itemElements.forEach(el => observer.observe(el))
224
+
225
+ return () => observer.disconnect()
226
+ }
227
+
228
+ let hourObserverCleanup = () => {}
229
+ let minuteObserverCleanup = () => {}
230
+ let periodObserverCleanup = () => {}
231
+
232
+ function setupObservers() {
233
+ hourObserverCleanup()
234
+ minuteObserverCleanup()
235
+ periodObserverCleanup()
236
+
237
+ hourObserverCleanup = setupScrollObserver(
238
+ hourColumnRef.value,
239
+ hours.value,
240
+ (val) => {
241
+ selectedHour.value = val as number
242
+ updateInputDisplay()
243
+ emitValue()
244
+ }
245
+ )
246
+
247
+ minuteObserverCleanup = setupScrollObserver(
248
+ minuteColumnRef.value,
249
+ minutes.value,
250
+ (val) => {
251
+ selectedMinute.value = val as number
252
+ updateInputDisplay()
253
+ emitValue()
254
+ }
255
+ )
256
+
257
+ if (props.format === '12h') {
258
+ periodObserverCleanup = setupScrollObserver(
259
+ periodColumnRef.value,
260
+ periods,
261
+ (val) => {
262
+ selectedPeriod.value = val as 'AM' | 'PM'
263
+ updateInputDisplay()
264
+ emitValue()
265
+ }
266
+ )
267
+ }
268
+ }
269
+
270
+ function handleScroll() {
271
+ isScrolling.value = true
272
+
273
+ if (scrollTimeout.value) {
274
+ clearTimeout(scrollTimeout.value)
275
+ }
276
+
277
+ scrollTimeout.value = window.setTimeout(() => {
278
+ isScrolling.value = false
279
+ }, 150)
280
+ }
281
+
282
+ function handleItemClick(column: 'hour' | 'minute' | 'period', value: number | string) {
283
+ if (column === 'hour') {
284
+ selectedHour.value = value as number
285
+ scrollColumnToValue(hourColumnRef.value, value, hours.value)
286
+ } else if (column === 'minute') {
287
+ selectedMinute.value = value as number
288
+ scrollColumnToValue(minuteColumnRef.value, value, minutes.value)
289
+ } else if (column === 'period') {
290
+ selectedPeriod.value = value as 'AM' | 'PM'
291
+ scrollColumnToValue(periodColumnRef.value, value, periods)
292
+ }
293
+
294
+ updateInputDisplay()
295
+ emitValue()
296
+
297
+ if (props.closeOnSelect && column === 'minute') {
298
+ popoverRef.value?.close()
299
+ }
300
+ }
301
+
302
+ function handlePopoverOpen() {
303
+ nextTick(() => {
304
+ scrollToSelected()
305
+ setupObservers()
306
+ })
307
+ }
308
+
309
+ function handlePopoverClose() {
310
+ hourObserverCleanup()
311
+ minuteObserverCleanup()
312
+ periodObserverCleanup()
313
+ }
314
+
315
+ function padValue(value: number): string {
316
+ return value.toString().padStart(2, '0')
317
+ }
318
+
319
+ watch(() => props.modelValue, () => {
320
+ initializeFromModelValue()
321
+ }, { immediate: true })
322
+
323
+ onMounted(() => {
324
+ initializeFromModelValue()
325
+ })
326
+
327
+ onUnmounted(() => {
328
+ hourObserverCleanup()
329
+ minuteObserverCleanup()
330
+ periodObserverCleanup()
331
+ if (scrollTimeout.value) {
332
+ clearTimeout(scrollTimeout.value)
333
+ }
334
+ })
335
+
336
+ defineExpose({
337
+ open: () => popoverRef.value?.open(),
338
+ close: () => popoverRef.value?.close(),
339
+ toggle: () => popoverRef.value?.toggle()
340
+ })
341
+ </script>
342
+
343
+ <template>
344
+ <div
345
+ class="ui-timepicker"
346
+ :class="[
347
+ `ui-timepicker--${size}`,
348
+ {
349
+ 'ui-timepicker--block': block,
350
+ 'ui-timepicker--disabled': disabled
351
+ }
352
+ ]"
353
+ >
354
+ <Popover
355
+ ref="popoverRef"
356
+ placement="bottom-start"
357
+ :disabled="disabled"
358
+ :close-on-click-outside="true"
359
+ :close-on-escape="true"
360
+ :trap-focus="true"
361
+ @open="handlePopoverOpen"
362
+ @close="handlePopoverClose"
363
+ >
364
+ <template #trigger>
365
+ <Input
366
+ :id="inputId"
367
+ v-model="inputValue"
368
+ :label="label"
369
+ :placeholder="placeholder || (format === '12h' ? 'hh:mm AM/PM' : 'HH:mm')"
370
+ :hint="hint"
371
+ :error="error"
372
+ :size="size"
373
+ :disabled="disabled"
374
+ :required="required"
375
+ :name="name"
376
+ :block="block"
377
+ :icon-right="calendarIcon"
378
+ autocomplete="off"
379
+ @update:model-value="handleInputChange"
380
+ @blur="handleInputBlur"
381
+ @focus="handleInputFocus"
382
+ />
383
+ </template>
384
+
385
+ <div class="ui-timepicker__panel">
386
+ <div class="ui-timepicker__columns">
387
+ <div class="ui-timepicker__highlight" />
388
+
389
+ <div
390
+ ref="hourColumnRef"
391
+ class="ui-timepicker__column"
392
+ @scroll="handleScroll"
393
+ >
394
+ <div class="ui-timepicker__spacer" />
395
+ <div
396
+ v-for="hour in hours"
397
+ :key="hour"
398
+ :data-value="hour"
399
+ class="ui-timepicker__item"
400
+ :class="{ 'ui-timepicker__item--selected': hour === selectedHour }"
401
+ @click="handleItemClick('hour', hour)"
402
+ >
403
+ {{ padValue(hour) }}
404
+ </div>
405
+ <div class="ui-timepicker__spacer" />
406
+ </div>
407
+
408
+ <div class="ui-timepicker__separator">:</div>
409
+
410
+ <div
411
+ ref="minuteColumnRef"
412
+ class="ui-timepicker__column"
413
+ @scroll="handleScroll"
414
+ >
415
+ <div class="ui-timepicker__spacer" />
416
+ <div
417
+ v-for="minute in minutes"
418
+ :key="minute"
419
+ :data-value="minute"
420
+ class="ui-timepicker__item"
421
+ :class="{ 'ui-timepicker__item--selected': minute === selectedMinute }"
422
+ @click="handleItemClick('minute', minute)"
423
+ >
424
+ {{ padValue(minute) }}
425
+ </div>
426
+ <div class="ui-timepicker__spacer" />
427
+ </div>
428
+
429
+ <template v-if="format === '12h'">
430
+ <div
431
+ ref="periodColumnRef"
432
+ class="ui-timepicker__column ui-timepicker__column--period"
433
+ @scroll="handleScroll"
434
+ >
435
+ <div class="ui-timepicker__spacer" />
436
+ <div
437
+ v-for="period in periods"
438
+ :key="period"
439
+ :data-value="period"
440
+ class="ui-timepicker__item"
441
+ :class="{ 'ui-timepicker__item--selected': period === selectedPeriod }"
442
+ @click="handleItemClick('period', period)"
443
+ >
444
+ {{ period }}
445
+ </div>
446
+ <div class="ui-timepicker__spacer" />
447
+ </div>
448
+ </template>
449
+ </div>
450
+ </div>
451
+ </Popover>
452
+ </div>
453
+ </template>
454
+
455
+ <style scoped>
456
+ .ui-timepicker {
457
+ display: inline-block;
458
+ font-family: var(--font-sans);
459
+ }
460
+
461
+ .ui-timepicker--block {
462
+ display: block;
463
+ width: 100%;
464
+ }
465
+
466
+ .ui-timepicker__panel {
467
+ padding: 0;
468
+ min-width: 180px;
469
+ }
470
+
471
+ .ui-timepicker__columns {
472
+ display: flex;
473
+ align-items: center;
474
+ height: 200px;
475
+ position: relative;
476
+ background: linear-gradient(
477
+ to bottom,
478
+ var(--popover-bg, var(--dropdown-bg)) 0%,
479
+ transparent 40%,
480
+ transparent 60%,
481
+ var(--popover-bg, var(--dropdown-bg)) 100%
482
+ );
483
+ }
484
+
485
+ .ui-timepicker__highlight {
486
+ position: absolute;
487
+ top: 50%;
488
+ left: 0;
489
+ right: 0;
490
+ height: 40px;
491
+ transform: translateY(-50%);
492
+ border-top: 1px solid var(--input-border);
493
+ border-bottom: 1px solid var(--input-border);
494
+ background-color: var(--input-bg);
495
+ pointer-events: none;
496
+ z-index: 0;
497
+ }
498
+
499
+ .ui-timepicker__column {
500
+ flex: 1;
501
+ height: 100%;
502
+ overflow-y: auto;
503
+ scroll-snap-type: y mandatory;
504
+ scrollbar-width: none;
505
+ position: relative;
506
+ z-index: 1;
507
+ }
508
+
509
+ .ui-timepicker__column::-webkit-scrollbar {
510
+ display: none;
511
+ }
512
+
513
+ .ui-timepicker__column--period {
514
+ flex: 0 0 auto;
515
+ min-width: 50px;
516
+ }
517
+
518
+ .ui-timepicker__spacer {
519
+ height: 80px;
520
+ scroll-snap-align: none;
521
+ }
522
+
523
+ .ui-timepicker__item {
524
+ height: 40px;
525
+ display: flex;
526
+ align-items: center;
527
+ justify-content: center;
528
+ scroll-snap-align: center;
529
+ cursor: pointer;
530
+ font-size: var(--text-md);
531
+ font-weight: var(--font-normal);
532
+ color: var(--input-text);
533
+ transition: color var(--duration-fast) var(--ease-default);
534
+ user-select: none;
535
+ }
536
+
537
+ .ui-timepicker__item:hover {
538
+ color: var(--action-primary);
539
+ }
540
+
541
+ .ui-timepicker__item--selected {
542
+ font-weight: var(--font-medium);
543
+ color: var(--action-primary);
544
+ }
545
+
546
+ .ui-timepicker__separator {
547
+ display: flex;
548
+ align-items: center;
549
+ justify-content: center;
550
+ font-size: var(--text-lg);
551
+ font-weight: var(--font-medium);
552
+ color: var(--input-text);
553
+ padding: 0 var(--space-1);
554
+ }
555
+
556
+ @media (max-width: 640px) {
557
+ .ui-timepicker__panel {
558
+ min-width: 100%;
559
+ }
560
+
561
+ .ui-timepicker__columns {
562
+ height: 240px;
563
+ }
564
+
565
+ .ui-timepicker__spacer {
566
+ height: 100px;
567
+ }
568
+ }
569
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as TimePicker } from './TimePicker.vue'
2
+ export type { TimePickerProps } from './TimePicker.vue'