@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,435 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, onUnmounted } from 'vue'
3
+ import { valueToPercent, getValueFromPointer, getClosestThumb, clamp, snapToStep } from './utils'
4
+
5
+ export interface SliderProps {
6
+ /** Current value (number for single, [min, max] tuple for range) */
7
+ modelValue: number | [number, number]
8
+ /** Minimum value */
9
+ min?: number
10
+ /** Maximum value */
11
+ max?: number
12
+ /** Step increment */
13
+ step?: number
14
+ /** Disabled state */
15
+ disabled?: boolean
16
+ /** Show tooltip while dragging */
17
+ showTooltip?: boolean
18
+ /** Show tick marks at each step */
19
+ showTicks?: boolean
20
+ /** Size variant */
21
+ size?: 'sm' | 'md' | 'lg'
22
+ }
23
+
24
+ const props = withDefaults(defineProps<SliderProps>(), {
25
+ min: 0,
26
+ max: 100,
27
+ step: 1,
28
+ disabled: false,
29
+ showTooltip: false,
30
+ showTicks: false,
31
+ size: 'md'
32
+ })
33
+
34
+ const emit = defineEmits<{
35
+ (e: 'update:modelValue', value: number | [number, number]): void
36
+ (e: 'change', value: number | [number, number]): void
37
+ }>()
38
+
39
+ const trackRef = ref<HTMLDivElement | null>(null)
40
+ const isDragging = ref(false)
41
+ const activeThumb = ref<'min' | 'max' | null>(null)
42
+
43
+ const isRange = computed(() => Array.isArray(props.modelValue))
44
+
45
+ const minValue = computed(() => {
46
+ if (isRange.value) {
47
+ return (props.modelValue as [number, number])[0]
48
+ }
49
+ return props.modelValue as number
50
+ })
51
+
52
+ const maxValue = computed(() => {
53
+ if (isRange.value) {
54
+ return (props.modelValue as [number, number])[1]
55
+ }
56
+ return props.modelValue as number
57
+ })
58
+
59
+ const minPercent = computed(() => {
60
+ if (isRange.value) {
61
+ return valueToPercent(minValue.value, props.min, props.max)
62
+ }
63
+ return 0
64
+ })
65
+
66
+ const maxPercent = computed(() => {
67
+ return valueToPercent(maxValue.value, props.min, props.max)
68
+ })
69
+
70
+ const rangeStyle = computed(() => {
71
+ if (isRange.value) {
72
+ return {
73
+ left: `${minPercent.value}%`,
74
+ right: `${100 - maxPercent.value}%`
75
+ }
76
+ }
77
+ return {
78
+ left: '0%',
79
+ right: `${100 - maxPercent.value}%`
80
+ }
81
+ })
82
+
83
+ const tickPositions = computed(() => {
84
+ if (!props.showTicks || props.step <= 0) return []
85
+
86
+ const ticks: number[] = []
87
+ const range = props.max - props.min
88
+ const stepCount = Math.floor(range / props.step)
89
+
90
+ if (stepCount > 100 || stepCount < 2) return []
91
+
92
+ for (let i = 1; i < stepCount; i++) {
93
+ const value = props.min + i * props.step
94
+ ticks.push(valueToPercent(value, props.min, props.max))
95
+ }
96
+ return ticks
97
+ })
98
+
99
+ function emitValue(newMin: number, newMax: number) {
100
+ if (isRange.value) {
101
+ emit('update:modelValue', [newMin, newMax])
102
+ } else {
103
+ emit('update:modelValue', newMax)
104
+ }
105
+ }
106
+
107
+ function emitChange(newMin: number, newMax: number) {
108
+ if (isRange.value) {
109
+ emit('change', [newMin, newMax])
110
+ } else {
111
+ emit('change', newMax)
112
+ }
113
+ }
114
+
115
+ function handlePointerDown(event: PointerEvent, thumb?: 'min' | 'max') {
116
+ if (props.disabled) return
117
+ event.preventDefault()
118
+
119
+ const track = trackRef.value
120
+ if (!track) return
121
+
122
+ const trackRect = track.getBoundingClientRect()
123
+ const value = getValueFromPointer(event.clientX, trackRect, props.min, props.max, props.step)
124
+
125
+ if (thumb) {
126
+ activeThumb.value = thumb
127
+ } else if (isRange.value) {
128
+ activeThumb.value = getClosestThumb(value, minValue.value, maxValue.value)
129
+ } else {
130
+ activeThumb.value = 'max'
131
+ }
132
+
133
+ isDragging.value = true
134
+ updateValue(value)
135
+
136
+ document.addEventListener('pointermove', handlePointerMove)
137
+ document.addEventListener('pointerup', handlePointerUp)
138
+ }
139
+
140
+ function handlePointerMove(event: PointerEvent) {
141
+ if (!isDragging.value || !trackRef.value) return
142
+
143
+ const trackRect = trackRef.value.getBoundingClientRect()
144
+ const value = getValueFromPointer(event.clientX, trackRect, props.min, props.max, props.step)
145
+ updateValue(value)
146
+ }
147
+
148
+ function handlePointerUp() {
149
+ if (isDragging.value) {
150
+ emitChange(minValue.value, maxValue.value)
151
+ }
152
+
153
+ isDragging.value = false
154
+ activeThumb.value = null
155
+ document.removeEventListener('pointermove', handlePointerMove)
156
+ document.removeEventListener('pointerup', handlePointerUp)
157
+ }
158
+
159
+ function updateValue(value: number) {
160
+ if (isRange.value) {
161
+ if (activeThumb.value === 'min') {
162
+ const newMin = clamp(value, props.min, maxValue.value)
163
+ emitValue(newMin, maxValue.value)
164
+ } else {
165
+ const newMax = clamp(value, minValue.value, props.max)
166
+ emitValue(minValue.value, newMax)
167
+ }
168
+ } else {
169
+ emitValue(props.min, value)
170
+ }
171
+ }
172
+
173
+ function handleKeyDown(event: KeyboardEvent, thumb: 'min' | 'max') {
174
+ if (props.disabled) return
175
+
176
+ const currentValue = thumb === 'min' ? minValue.value : maxValue.value
177
+ let newValue = currentValue
178
+
179
+ switch (event.key) {
180
+ case 'ArrowRight':
181
+ case 'ArrowUp':
182
+ event.preventDefault()
183
+ newValue = snapToStep(currentValue + props.step, props.step, props.min)
184
+ break
185
+ case 'ArrowLeft':
186
+ case 'ArrowDown':
187
+ event.preventDefault()
188
+ newValue = snapToStep(currentValue - props.step, props.step, props.min)
189
+ break
190
+ case 'Home':
191
+ event.preventDefault()
192
+ newValue = props.min
193
+ break
194
+ case 'End':
195
+ event.preventDefault()
196
+ newValue = props.max
197
+ break
198
+ case 'PageUp':
199
+ event.preventDefault()
200
+ newValue = snapToStep(currentValue + props.step * 10, props.step, props.min)
201
+ break
202
+ case 'PageDown':
203
+ event.preventDefault()
204
+ newValue = snapToStep(currentValue - props.step * 10, props.step, props.min)
205
+ break
206
+ default:
207
+ return
208
+ }
209
+
210
+ if (isRange.value) {
211
+ if (thumb === 'min') {
212
+ newValue = clamp(newValue, props.min, maxValue.value)
213
+ emitValue(newValue, maxValue.value)
214
+ } else {
215
+ newValue = clamp(newValue, minValue.value, props.max)
216
+ emitValue(minValue.value, newValue)
217
+ }
218
+ } else {
219
+ newValue = clamp(newValue, props.min, props.max)
220
+ emitValue(props.min, newValue)
221
+ }
222
+
223
+ emitChange(minValue.value, maxValue.value)
224
+ }
225
+
226
+ onUnmounted(() => {
227
+ document.removeEventListener('pointermove', handlePointerMove)
228
+ document.removeEventListener('pointerup', handlePointerUp)
229
+ })
230
+ </script>
231
+
232
+ <template>
233
+ <div
234
+ class="ui-slider"
235
+ :class="[
236
+ `ui-slider--${size}`,
237
+ {
238
+ 'ui-slider--disabled': disabled,
239
+ 'ui-slider--dragging': isDragging,
240
+ 'ui-slider--range': isRange
241
+ }
242
+ ]"
243
+ >
244
+ <div
245
+ ref="trackRef"
246
+ class="ui-slider__track"
247
+ @pointerdown="handlePointerDown($event)"
248
+ >
249
+ <div class="ui-slider__range" :style="rangeStyle" />
250
+
251
+ <div v-if="showTicks" class="ui-slider__ticks">
252
+ <span
253
+ v-for="(pos, i) in tickPositions"
254
+ :key="i"
255
+ class="ui-slider__tick"
256
+ :style="{ left: `${pos}%` }"
257
+ />
258
+ </div>
259
+
260
+ <div
261
+ v-if="isRange"
262
+ class="ui-slider__thumb"
263
+ :class="{ 'ui-slider__thumb--active': activeThumb === 'min' }"
264
+ :style="{ left: `${minPercent}%` }"
265
+ role="slider"
266
+ tabindex="0"
267
+ :aria-valuemin="min"
268
+ :aria-valuemax="maxValue"
269
+ :aria-valuenow="minValue"
270
+ :aria-disabled="disabled || undefined"
271
+ aria-label="Minimum value"
272
+ @pointerdown.stop="handlePointerDown($event, 'min')"
273
+ @keydown="handleKeyDown($event, 'min')"
274
+ >
275
+ <div v-if="showTooltip && isDragging && activeThumb === 'min'" class="ui-slider__tooltip">
276
+ {{ minValue }}
277
+ </div>
278
+ </div>
279
+
280
+ <div
281
+ class="ui-slider__thumb"
282
+ :class="{ 'ui-slider__thumb--active': activeThumb === 'max' }"
283
+ :style="{ left: `${maxPercent}%` }"
284
+ role="slider"
285
+ tabindex="0"
286
+ :aria-valuemin="isRange ? minValue : min"
287
+ :aria-valuemax="max"
288
+ :aria-valuenow="maxValue"
289
+ :aria-disabled="disabled || undefined"
290
+ :aria-label="isRange ? 'Maximum value' : 'Value'"
291
+ @pointerdown.stop="handlePointerDown($event, 'max')"
292
+ @keydown="handleKeyDown($event, 'max')"
293
+ >
294
+ <div v-if="showTooltip && isDragging && activeThumb === 'max'" class="ui-slider__tooltip">
295
+ {{ maxValue }}
296
+ </div>
297
+ </div>
298
+ </div>
299
+ </div>
300
+ </template>
301
+
302
+ <style scoped>
303
+ .ui-slider {
304
+ position: relative;
305
+ width: 100%;
306
+ padding: var(--space-2) 0;
307
+ touch-action: none;
308
+ user-select: none;
309
+ }
310
+
311
+ .ui-slider--disabled {
312
+ opacity: 0.5;
313
+ pointer-events: none;
314
+ }
315
+
316
+ .ui-slider__track {
317
+ position: relative;
318
+ width: 100%;
319
+ height: var(--slider-track-height, 6px);
320
+ background: var(--input-border);
321
+ border-radius: var(--radius-full);
322
+ cursor: pointer;
323
+ }
324
+
325
+ .ui-slider--sm .ui-slider__track {
326
+ height: 4px;
327
+ }
328
+
329
+ .ui-slider--lg .ui-slider__track {
330
+ height: 8px;
331
+ }
332
+
333
+ .ui-slider__range {
334
+ position: absolute;
335
+ top: 0;
336
+ bottom: 0;
337
+ background: var(--action-primary);
338
+ border-radius: var(--radius-full);
339
+ pointer-events: none;
340
+ }
341
+
342
+ .ui-slider__ticks {
343
+ position: absolute;
344
+ inset: 0;
345
+ pointer-events: none;
346
+ }
347
+
348
+ .ui-slider__tick {
349
+ position: absolute;
350
+ top: 50%;
351
+ width: 2px;
352
+ height: calc(100% + 6px);
353
+ background: var(--border-default);
354
+ transform: translate(-50%, -50%);
355
+ border-radius: var(--radius-full);
356
+ }
357
+
358
+ .ui-slider--sm .ui-slider__tick {
359
+ height: calc(100% + 4px);
360
+ }
361
+
362
+ .ui-slider--lg .ui-slider__tick {
363
+ height: calc(100% + 8px);
364
+ }
365
+
366
+ .ui-slider__thumb {
367
+ position: absolute;
368
+ top: 50%;
369
+ width: var(--slider-thumb-size, 18px);
370
+ height: var(--slider-thumb-size, 18px);
371
+ background: var(--bg-primary);
372
+ border: 2px solid var(--action-primary);
373
+ border-radius: var(--radius-full);
374
+ transform: translate(-50%, -50%);
375
+ cursor: grab;
376
+ transition: box-shadow var(--duration-fast) var(--ease-default);
377
+ z-index: 1;
378
+ }
379
+
380
+ .ui-slider--sm .ui-slider__thumb {
381
+ width: 14px;
382
+ height: 14px;
383
+ }
384
+
385
+ .ui-slider--lg .ui-slider__thumb {
386
+ width: 22px;
387
+ height: 22px;
388
+ }
389
+
390
+ .ui-slider__thumb:hover {
391
+ box-shadow: 0 0 0 4px oklch(from var(--action-primary) l c h / 0.15);
392
+ }
393
+
394
+ .ui-slider__thumb:focus-visible {
395
+ outline: none;
396
+ box-shadow: 0 0 0 4px oklch(from var(--action-primary) l c h / 0.25);
397
+ }
398
+
399
+ .ui-slider__thumb--active {
400
+ cursor: grabbing;
401
+ z-index: 2;
402
+ box-shadow: 0 0 0 6px oklch(from var(--action-primary) l c h / 0.2);
403
+ }
404
+
405
+ .ui-slider--dragging .ui-slider__thumb {
406
+ cursor: grabbing;
407
+ }
408
+
409
+ .ui-slider__tooltip {
410
+ position: absolute;
411
+ bottom: calc(100% + 8px);
412
+ left: 50%;
413
+ transform: translateX(-50%);
414
+ padding: var(--space-1) var(--space-2);
415
+ background: var(--bg-elevated);
416
+ border: 1px solid var(--border-default);
417
+ border-radius: var(--radius-md);
418
+ font-size: var(--text-xs);
419
+ font-weight: var(--font-medium);
420
+ color: var(--text-primary);
421
+ white-space: nowrap;
422
+ box-shadow: var(--shadow-md);
423
+ pointer-events: none;
424
+ }
425
+
426
+ .ui-slider__tooltip::after {
427
+ content: '';
428
+ position: absolute;
429
+ top: 100%;
430
+ left: 50%;
431
+ transform: translateX(-50%);
432
+ border: 4px solid transparent;
433
+ border-top-color: var(--bg-elevated);
434
+ }
435
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as Slider } from './Slider.vue'
2
+ export type { SliderProps } from './Slider.vue'
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Converts a value to a percentage position on the slider track.
3
+ *
4
+ * @param value - The current value
5
+ * @param min - Minimum value of the range
6
+ * @param max - Maximum value of the range
7
+ * @returns Percentage (0-100)
8
+ */
9
+ export function valueToPercent(value: number, min: number, max: number): number {
10
+ if (max === min) return 0
11
+ return ((value - min) / (max - min)) * 100
12
+ }
13
+
14
+ /**
15
+ * Converts a percentage position to a value.
16
+ *
17
+ * @param percent - Percentage (0-100)
18
+ * @param min - Minimum value of the range
19
+ * @param max - Maximum value of the range
20
+ * @returns The calculated value
21
+ */
22
+ export function percentToValue(percent: number, min: number, max: number): number {
23
+ return (percent / 100) * (max - min) + min
24
+ }
25
+
26
+ /**
27
+ * Snaps a value to the nearest step increment.
28
+ *
29
+ * @param value - The value to snap
30
+ * @param step - Step increment
31
+ * @param min - Minimum value (used as base for snapping)
32
+ * @returns Snapped value
33
+ */
34
+ export function snapToStep(value: number, step: number, min: number): number {
35
+ const offset = value - min
36
+ const snapped = Math.round(offset / step) * step
37
+ return snapped + min
38
+ }
39
+
40
+ /**
41
+ * Clamps a value between min and max.
42
+ *
43
+ * @param value - The value to clamp
44
+ * @param min - Minimum allowed value
45
+ * @param max - Maximum allowed value
46
+ * @returns Clamped value
47
+ */
48
+ export function clamp(value: number, min: number, max: number): number {
49
+ return Math.min(Math.max(value, min), max)
50
+ }
51
+
52
+ /**
53
+ * Calculates the value from a pointer position on the track.
54
+ *
55
+ * @param clientX - Pointer X position
56
+ * @param trackRect - Track element's bounding rect
57
+ * @param min - Minimum value
58
+ * @param max - Maximum value
59
+ * @param step - Step increment
60
+ * @returns Calculated and snapped value
61
+ */
62
+ export function getValueFromPointer(
63
+ clientX: number,
64
+ trackRect: DOMRect,
65
+ min: number,
66
+ max: number,
67
+ step: number
68
+ ): number {
69
+ const percent = ((clientX - trackRect.left) / trackRect.width) * 100
70
+ const clampedPercent = clamp(percent, 0, 100)
71
+ const rawValue = percentToValue(clampedPercent, min, max)
72
+ return clamp(snapToStep(rawValue, step, min), min, max)
73
+ }
74
+
75
+ /**
76
+ * Determines which thumb is closer to a given value in range mode.
77
+ *
78
+ * @param value - The target value
79
+ * @param minValue - Current min thumb value
80
+ * @param maxValue - Current max thumb value
81
+ * @returns 'min' or 'max'
82
+ */
83
+ export function getClosestThumb(
84
+ value: number,
85
+ minValue: number,
86
+ maxValue: number
87
+ ): 'min' | 'max' {
88
+ const distToMin = Math.abs(value - minValue)
89
+ const distToMax = Math.abs(value - maxValue)
90
+ return distToMin <= distToMax ? 'min' : 'max'
91
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import Spinner from './Spinner.vue'
4
+
5
+ describe('Spinner', () => {
6
+ it('renders 8 dots', () => {
7
+ const wrapper = mount(Spinner)
8
+ const dots = wrapper.findAll('.ui-spinner__dot')
9
+ expect(dots).toHaveLength(8)
10
+ })
11
+
12
+ it('has role="status" for accessibility', () => {
13
+ const wrapper = mount(Spinner)
14
+ expect(wrapper.attributes('role')).toBe('status')
15
+ })
16
+
17
+ it('has default aria-label "Loading"', () => {
18
+ const wrapper = mount(Spinner)
19
+ expect(wrapper.attributes('aria-label')).toBe('Loading')
20
+ })
21
+
22
+ it('accepts custom label', () => {
23
+ const wrapper = mount(Spinner, {
24
+ props: { label: 'Processing request' }
25
+ })
26
+ expect(wrapper.attributes('aria-label')).toBe('Processing request')
27
+ expect(wrapper.find('.ui-spinner__sr-only').text()).toBe('Processing request')
28
+ })
29
+
30
+ it('applies default size (md)', () => {
31
+ const wrapper = mount(Spinner)
32
+ expect(wrapper.attributes('style')).toContain('--spinner-size: var(--spinner-md)')
33
+ })
34
+
35
+ it('applies predefined sizes correctly', () => {
36
+ const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const
37
+
38
+ for (const size of sizes) {
39
+ const wrapper = mount(Spinner, { props: { size } })
40
+ expect(wrapper.attributes('style')).toContain(`--spinner-size: var(--spinner-${size})`)
41
+ }
42
+ })
43
+
44
+ it('accepts custom size values', () => {
45
+ const wrapper = mount(Spinner, {
46
+ props: { size: '4rem' }
47
+ })
48
+ expect(wrapper.attributes('style')).toContain('--spinner-size: 4rem')
49
+ })
50
+
51
+ it('applies default speed (0.9s)', () => {
52
+ const wrapper = mount(Spinner)
53
+ expect(wrapper.attributes('style')).toContain('--spinner-speed: 0.9s')
54
+ })
55
+
56
+ it('accepts custom speed', () => {
57
+ const wrapper = mount(Spinner, {
58
+ props: { speed: 1.5 }
59
+ })
60
+ expect(wrapper.attributes('style')).toContain('--spinner-speed: 1.5s')
61
+ })
62
+
63
+ it('has screen reader only text', () => {
64
+ const wrapper = mount(Spinner)
65
+ const srOnly = wrapper.find('.ui-spinner__sr-only')
66
+ expect(srOnly.exists()).toBe(true)
67
+ expect(srOnly.text()).toBe('Loading')
68
+ })
69
+
70
+ it('applies ui-spinner class', () => {
71
+ const wrapper = mount(Spinner)
72
+ expect(wrapper.classes()).toContain('ui-spinner')
73
+ })
74
+
75
+ it('uses inline-flex display for inline usage', () => {
76
+ const wrapper = mount(Spinner)
77
+ expect(wrapper.classes()).toContain('ui-spinner')
78
+ })
79
+ })