@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,171 @@
1
+ import { ref, watch, onUnmounted, type Ref } from 'vue'
2
+
3
+ export interface UseMagneticOptions {
4
+ strength?: number
5
+ radius?: number
6
+ ease?: number
7
+ }
8
+
9
+ export interface UseMagneticReturn {
10
+ x: Ref<number>
11
+ y: Ref<number>
12
+ isHovering: Ref<boolean>
13
+ style: Ref<Record<string, string>>
14
+ }
15
+
16
+ /**
17
+ * Creates a magnetic hover effect where the element subtly follows the cursor.
18
+ * Handles deferred elements (tabs, v-if) by watching for target availability.
19
+ *
20
+ * @param target - Ref to the target element
21
+ * @param options - Configuration options
22
+ * @returns Reactive values for position and style
23
+ *
24
+ * @example
25
+ * ```vue
26
+ * <script setup>
27
+ * const buttonRef = ref<HTMLElement | null>(null)
28
+ * const { style } = useMagnetic(buttonRef, { strength: 0.3 })
29
+ * </script>
30
+ *
31
+ * <template>
32
+ * <button ref="buttonRef" :style="style">Magnetic Button</button>
33
+ * </template>
34
+ * ```
35
+ */
36
+ export function useMagnetic(
37
+ target: Ref<HTMLElement | null>,
38
+ options: UseMagneticOptions = {}
39
+ ): UseMagneticReturn {
40
+ const { strength = 0.2, radius = 100, ease = 0.15 } = options
41
+
42
+ const x = ref(0)
43
+ const y = ref(0)
44
+ const isHovering = ref(false)
45
+ const style = ref<Record<string, string>>({})
46
+
47
+ let animationFrame: number | null = null
48
+ let targetX = 0
49
+ let targetY = 0
50
+ let currentX = 0
51
+ let currentY = 0
52
+ let isInitialized = false
53
+ let currentElement: HTMLElement | null = null
54
+
55
+ function lerp(start: number, end: number, factor: number): number {
56
+ return start + (end - start) * factor
57
+ }
58
+
59
+ function animate() {
60
+ currentX = lerp(currentX, targetX, ease)
61
+ currentY = lerp(currentY, targetY, ease)
62
+
63
+ const deltaX = Math.abs(currentX - targetX)
64
+ const deltaY = Math.abs(currentY - targetY)
65
+
66
+ if (deltaX < 0.01 && deltaY < 0.01) {
67
+ currentX = targetX
68
+ currentY = targetY
69
+ }
70
+
71
+ x.value = currentX
72
+ y.value = currentY
73
+ style.value = {
74
+ transform: `translate(${currentX}px, ${currentY}px)`,
75
+ transition: 'none'
76
+ }
77
+
78
+ if (isHovering.value || currentX !== 0 || currentY !== 0) {
79
+ animationFrame = requestAnimationFrame(animate)
80
+ } else {
81
+ animationFrame = null
82
+ style.value = {
83
+ transform: 'translate(0, 0)',
84
+ transition: 'transform var(--duration-normal, 200ms) var(--ease-out, ease-out)'
85
+ }
86
+ }
87
+ }
88
+
89
+ function handleMouseMove(event: MouseEvent) {
90
+ if (!target.value) return
91
+
92
+ const rect = target.value.getBoundingClientRect()
93
+ const centerX = rect.left + rect.width / 2
94
+ const centerY = rect.top + rect.height / 2
95
+
96
+ const distanceX = event.clientX - centerX
97
+ const distanceY = event.clientY - centerY
98
+ const distance = Math.sqrt(distanceX ** 2 + distanceY ** 2)
99
+
100
+ if (distance < radius) {
101
+ isHovering.value = true
102
+ const factor = 1 - distance / radius
103
+ targetX = distanceX * strength * factor
104
+ targetY = distanceY * strength * factor
105
+
106
+ if (!animationFrame) {
107
+ animationFrame = requestAnimationFrame(animate)
108
+ }
109
+ } else if (isHovering.value) {
110
+ handleMouseLeave()
111
+ }
112
+ }
113
+
114
+ function handleMouseLeave() {
115
+ isHovering.value = false
116
+ targetX = 0
117
+ targetY = 0
118
+
119
+ if (!animationFrame) {
120
+ animationFrame = requestAnimationFrame(animate)
121
+ }
122
+ }
123
+
124
+ function initialize(element: HTMLElement) {
125
+ if (isInitialized && currentElement === element) return
126
+
127
+ cleanup()
128
+
129
+ currentElement = element
130
+ isInitialized = true
131
+
132
+ document.addEventListener('mousemove', handleMouseMove)
133
+ element.addEventListener('mouseleave', handleMouseLeave)
134
+ }
135
+
136
+ function cleanup() {
137
+ document.removeEventListener('mousemove', handleMouseMove)
138
+ if (currentElement) {
139
+ currentElement.removeEventListener('mouseleave', handleMouseLeave)
140
+ }
141
+ if (animationFrame) {
142
+ cancelAnimationFrame(animationFrame)
143
+ animationFrame = null
144
+ }
145
+ isInitialized = false
146
+ currentElement = null
147
+ }
148
+
149
+ watch(
150
+ target,
151
+ (newTarget) => {
152
+ if (newTarget) {
153
+ initialize(newTarget)
154
+ } else {
155
+ cleanup()
156
+ }
157
+ },
158
+ { immediate: true }
159
+ )
160
+
161
+ onUnmounted(() => {
162
+ cleanup()
163
+ })
164
+
165
+ return {
166
+ x,
167
+ y,
168
+ isHovering,
169
+ style
170
+ }
171
+ }
@@ -0,0 +1,127 @@
1
+ import { ref, onMounted, onUnmounted, type Ref } from 'vue'
2
+
3
+ export type Placement = 'top' | 'bottom' | 'left' | 'right'
4
+
5
+ export interface PositionResult {
6
+ top: number
7
+ left: number
8
+ actualPlacement: Placement
9
+ }
10
+
11
+ /**
12
+ * Pure calculation function - no reactive dependencies.
13
+ * Can be called synchronously before first paint.
14
+ */
15
+ export function calculatePosition(
16
+ triggerEl: HTMLElement,
17
+ contentEl: HTMLElement,
18
+ desiredPlacement: Placement,
19
+ offset: number
20
+ ): PositionResult {
21
+ const triggerRect = triggerEl.getBoundingClientRect()
22
+
23
+ // Use offsetWidth/Height for content - not affected by CSS transforms (scale)
24
+ const contentWidth = contentEl.offsetWidth
25
+ const contentHeight = contentEl.offsetHeight
26
+
27
+ const viewport = {
28
+ width: window.innerWidth || 1024,
29
+ height: window.innerHeight || 768
30
+ }
31
+
32
+ const triggerCenterX = triggerRect.left + triggerRect.width / 2
33
+ const triggerCenterY = triggerRect.top + triggerRect.height / 2
34
+
35
+ function calcPosition(p: Placement): { top: number; left: number } {
36
+ switch (p) {
37
+ case 'top':
38
+ return {
39
+ top: triggerRect.top - contentHeight - offset,
40
+ left: triggerCenterX - contentWidth / 2
41
+ }
42
+ case 'bottom':
43
+ return {
44
+ top: triggerRect.bottom + offset,
45
+ left: triggerCenterX - contentWidth / 2
46
+ }
47
+ case 'left':
48
+ return {
49
+ top: triggerCenterY - contentHeight / 2,
50
+ left: triggerRect.left - contentWidth - offset
51
+ }
52
+ case 'right':
53
+ return {
54
+ top: triggerCenterY - contentHeight / 2,
55
+ left: triggerRect.right + offset
56
+ }
57
+ }
58
+ }
59
+
60
+ const opposites: Record<Placement, Placement> = {
61
+ top: 'bottom',
62
+ bottom: 'top',
63
+ left: 'right',
64
+ right: 'left'
65
+ }
66
+
67
+ let currentPlacement = desiredPlacement
68
+ let pos = calcPosition(currentPlacement)
69
+
70
+ const overflows = {
71
+ top: pos.top < 0,
72
+ bottom: pos.top + contentHeight > viewport.height,
73
+ left: pos.left < 0,
74
+ right: pos.left + contentWidth > viewport.width
75
+ }
76
+
77
+ if (
78
+ (currentPlacement === 'top' && overflows.top) ||
79
+ (currentPlacement === 'bottom' && overflows.bottom) ||
80
+ (currentPlacement === 'left' && overflows.left) ||
81
+ (currentPlacement === 'right' && overflows.right)
82
+ ) {
83
+ currentPlacement = opposites[currentPlacement]
84
+ pos = calcPosition(currentPlacement)
85
+ }
86
+
87
+ const top = Math.max(offset, Math.min(pos.top, viewport.height - contentHeight - offset))
88
+ const left = Math.max(offset, Math.min(pos.left, viewport.width - contentWidth - offset))
89
+
90
+ return { top, left, actualPlacement: currentPlacement }
91
+ }
92
+
93
+ /**
94
+ * Composable for positioning floating elements relative to a trigger.
95
+ * Includes collision detection to flip placement when near viewport edges.
96
+ */
97
+ export function useRelativePosition(
98
+ trigger: Ref<HTMLElement | null>,
99
+ content: Ref<HTMLElement | null>,
100
+ placement: Ref<Placement> | Placement | (() => Placement) = 'top',
101
+ offset = 8
102
+ ) {
103
+ const coords = ref<PositionResult>({ top: 0, left: 0, actualPlacement: 'top' })
104
+
105
+ function getPlacement(): Placement {
106
+ if (typeof placement === 'string') return placement
107
+ if (typeof placement === 'function') return placement()
108
+ return placement.value
109
+ }
110
+
111
+ function updatePosition() {
112
+ if (!trigger.value || !content.value) return
113
+ coords.value = calculatePosition(trigger.value, content.value, getPlacement(), offset)
114
+ }
115
+
116
+ onMounted(() => {
117
+ window.addEventListener('resize', updatePosition)
118
+ window.addEventListener('scroll', updatePosition, true)
119
+ })
120
+
121
+ onUnmounted(() => {
122
+ window.removeEventListener('resize', updatePosition)
123
+ window.removeEventListener('scroll', updatePosition, true)
124
+ })
125
+
126
+ return { coords, updatePosition, getPlacement }
127
+ }
@@ -0,0 +1,146 @@
1
+ import { watch, onUnmounted, type Ref } from 'vue'
2
+
3
+ export interface UseRippleOptions {
4
+ color?: string
5
+ opacity?: number
6
+ duration?: number
7
+ disabled?: Ref<boolean> | boolean
8
+ }
9
+
10
+ /**
11
+ * Creates a ripple effect on click for interactive elements.
12
+ * Handles deferred elements (tabs, v-if) by watching for target availability.
13
+ *
14
+ * @param target - Ref to the target element
15
+ * @param options - Configuration options
16
+ *
17
+ * @example
18
+ * ```vue
19
+ * <script setup>
20
+ * const buttonRef = ref<HTMLElement | null>(null)
21
+ * useRipple(buttonRef, { color: 'white', opacity: 0.3 })
22
+ * </script>
23
+ *
24
+ * <template>
25
+ * <button ref="buttonRef" data-ripple>Click me</button>
26
+ * </template>
27
+ * ```
28
+ */
29
+ export function useRipple(
30
+ target: Ref<HTMLElement | null>,
31
+ options: UseRippleOptions = {}
32
+ ): void {
33
+ const {
34
+ color = 'var(--effect-ripple-color, white)',
35
+ opacity = 0.25,
36
+ duration = 800,
37
+ disabled = false
38
+ } = options
39
+
40
+ let isInitialized = false
41
+ let currentElement: HTMLElement | null = null
42
+ let styleElement: HTMLStyleElement | null = null
43
+
44
+ function isDisabled(): boolean {
45
+ if (typeof disabled === 'boolean') return disabled
46
+ return disabled.value
47
+ }
48
+
49
+ function ensureKeyframes(): void {
50
+ if (document.querySelector('style[data-ripple-keyframes]')) return
51
+
52
+ styleElement = document.createElement('style')
53
+ styleElement.setAttribute('data-ripple-keyframes', '')
54
+ styleElement.textContent = `
55
+ @keyframes ui-ripple-expand {
56
+ 0% {
57
+ opacity: 0;
58
+ transform: scale(0);
59
+ }
60
+ 15% {
61
+ opacity: var(--ripple-opacity, 0.25);
62
+ }
63
+ 100% {
64
+ transform: scale(2.5);
65
+ opacity: 0;
66
+ }
67
+ }
68
+ `
69
+ document.head.appendChild(styleElement)
70
+ }
71
+
72
+ function createRipple(event: MouseEvent) {
73
+ if (!target.value || isDisabled()) return
74
+
75
+ const element = target.value
76
+ const rect = element.getBoundingClientRect()
77
+
78
+ const x = event.clientX - rect.left
79
+ const y = event.clientY - rect.top
80
+
81
+ const size = Math.max(rect.width, rect.height) * 2
82
+
83
+ const ripple = document.createElement('span')
84
+ ripple.className = 'ui-ripple'
85
+ ripple.style.cssText = `
86
+ position: absolute;
87
+ left: ${x - size / 2}px;
88
+ top: ${y - size / 2}px;
89
+ width: ${size}px;
90
+ height: ${size}px;
91
+ border-radius: 50%;
92
+ background: ${color};
93
+ --ripple-opacity: ${opacity};
94
+ opacity: 0;
95
+ transform: scale(0);
96
+ pointer-events: none;
97
+ animation: ui-ripple-expand ${duration}ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
98
+ `
99
+
100
+ element.style.position = element.style.position || 'relative'
101
+ element.style.overflow = 'hidden'
102
+
103
+ element.appendChild(ripple)
104
+
105
+ ripple.addEventListener('animationend', () => {
106
+ ripple.remove()
107
+ })
108
+ }
109
+
110
+ function initialize(element: HTMLElement) {
111
+ if (isInitialized && currentElement === element) return
112
+
113
+ cleanup()
114
+
115
+ currentElement = element
116
+ isInitialized = true
117
+
118
+ ensureKeyframes()
119
+ element.addEventListener('click', createRipple)
120
+ element.setAttribute('data-ripple', '')
121
+ }
122
+
123
+ function cleanup() {
124
+ if (currentElement) {
125
+ currentElement.removeEventListener('click', createRipple)
126
+ }
127
+ isInitialized = false
128
+ currentElement = null
129
+ }
130
+
131
+ watch(
132
+ target,
133
+ (newTarget) => {
134
+ if (newTarget) {
135
+ initialize(newTarget)
136
+ } else {
137
+ cleanup()
138
+ }
139
+ },
140
+ { immediate: true }
141
+ )
142
+
143
+ onUnmounted(() => {
144
+ cleanup()
145
+ })
146
+ }
@@ -0,0 +1,25 @@
1
+ import { onUnmounted, watch, type Ref } from 'vue'
2
+
3
+ /**
4
+ * Locks body scroll when active, preventing background scrolling.
5
+ * Compensates for scrollbar width to prevent layout shift.
6
+ */
7
+ export function useScrollLock(isLocked: Ref<boolean>) {
8
+ const lock = () => {
9
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
10
+ document.body.style.paddingRight = `${scrollbarWidth}px`
11
+ document.body.style.overflow = 'hidden'
12
+ }
13
+
14
+ const unlock = () => {
15
+ document.body.style.paddingRight = ''
16
+ document.body.style.overflow = ''
17
+ }
18
+
19
+ watch(isLocked, (val) => {
20
+ if (val) lock()
21
+ else unlock()
22
+ })
23
+
24
+ onUnmounted(() => unlock())
25
+ }
@@ -0,0 +1,27 @@
1
+ import { inject, type ComputedRef } from 'vue'
2
+ import { spireConfigKey, type SpireConfig } from '../components/SpireProvider/SpireProvider.vue'
3
+
4
+ /**
5
+ * Access the current Spire UI configuration from a parent SpireProvider.
6
+ *
7
+ * @returns The current theme configuration, or undefined if no provider exists
8
+ *
9
+ * @example
10
+ * ```vue
11
+ * <script setup>
12
+ * import { useSpireConfig } from 'spire-ui'
13
+ *
14
+ * const config = useSpireConfig()
15
+ * // config?.theme, config?.mood, config?.depth, config?.motion
16
+ * </script>
17
+ *
18
+ * <template>
19
+ * <div v-if="config">
20
+ * Current mood: {{ config.mood }}
21
+ * </div>
22
+ * </template>
23
+ * ```
24
+ */
25
+ export function useSpireConfig(): ComputedRef<SpireConfig> | undefined {
26
+ return inject(spireConfigKey, undefined)
27
+ }
@@ -0,0 +1,224 @@
1
+ import { ref, watch, nextTick, onUnmounted, type Ref } from 'vue'
2
+
3
+ export interface UseStaggerOptions {
4
+ delay?: number
5
+ duration?: number
6
+ easing?: string
7
+ from?: {
8
+ opacity?: number
9
+ transform?: string
10
+ }
11
+ to?: {
12
+ opacity?: number
13
+ transform?: string
14
+ }
15
+ animateOnVisible?: boolean
16
+ }
17
+
18
+ export interface UseStaggerReturn {
19
+ isAnimating: Ref<boolean>
20
+ animate: () => Promise<void>
21
+ reset: () => void
22
+ }
23
+
24
+ /**
25
+ * Creates sequential stagger animations for list items.
26
+ * Handles deferred elements (tabs, v-if) by watching for container availability.
27
+ *
28
+ * @param container - Ref to the container element
29
+ * @param itemSelector - CSS selector for items to animate
30
+ * @param options - Animation configuration
31
+ * @returns Controls for the stagger animation
32
+ *
33
+ * @example
34
+ * ```vue
35
+ * <script setup>
36
+ * const listRef = ref<HTMLElement | null>(null)
37
+ * const { animate } = useStagger(listRef, '.list-item', {
38
+ * delay: 80,
39
+ * from: { opacity: 0, transform: 'translateY(12px)' },
40
+ * to: { opacity: 1, transform: 'translateY(0)' }
41
+ * })
42
+ *
43
+ * // Trigger manually when ready
44
+ * function onTabVisible() {
45
+ * animate()
46
+ * }
47
+ * </script>
48
+ *
49
+ * <template>
50
+ * <ul ref="listRef">
51
+ * <li class="list-item" v-for="item in items" :key="item.id">{{ item.name }}</li>
52
+ * </ul>
53
+ * </template>
54
+ * ```
55
+ */
56
+ export function useStagger(
57
+ container: Ref<HTMLElement | null>,
58
+ itemSelector: string,
59
+ options: UseStaggerOptions = {}
60
+ ): UseStaggerReturn {
61
+ const {
62
+ delay = 80,
63
+ duration = 400,
64
+ easing = 'cubic-bezier(0.16, 1, 0.3, 1)',
65
+ from = { opacity: 0, transform: 'translateY(16px)' },
66
+ to = { opacity: 1, transform: 'translateY(0)' },
67
+ animateOnVisible = false
68
+ } = options
69
+
70
+ const isAnimating = ref(false)
71
+ let hasAnimatedOnce = false
72
+ let animationTimeout: ReturnType<typeof setTimeout> | null = null
73
+
74
+ function getItems(): HTMLElement[] {
75
+ if (!container.value) return []
76
+ return Array.from(container.value.querySelectorAll<HTMLElement>(itemSelector))
77
+ }
78
+
79
+ function forceReflow(element: HTMLElement): void {
80
+ void element.offsetHeight
81
+ }
82
+
83
+ function applyInitialStyles(items: HTMLElement[]): void {
84
+ items.forEach((item) => {
85
+ item.style.transition = 'none'
86
+ if (from.opacity !== undefined) {
87
+ item.style.opacity = String(from.opacity)
88
+ }
89
+ if (from.transform) {
90
+ item.style.transform = from.transform
91
+ }
92
+ })
93
+ }
94
+
95
+ async function animate(): Promise<void> {
96
+ await nextTick()
97
+
98
+ const items = getItems()
99
+ if (items.length === 0) return
100
+
101
+ if (isAnimating.value) return
102
+ isAnimating.value = true
103
+
104
+ applyInitialStyles(items)
105
+
106
+ if (items[0]) {
107
+ forceReflow(items[0])
108
+ }
109
+
110
+ await new Promise(resolve => requestAnimationFrame(resolve))
111
+
112
+ items.forEach((item, index) => {
113
+ const itemDelay = index * delay
114
+
115
+ item.style.transition = `opacity ${duration}ms ${easing} ${itemDelay}ms, transform ${duration}ms ${easing} ${itemDelay}ms`
116
+
117
+ if (to.opacity !== undefined) {
118
+ item.style.opacity = String(to.opacity)
119
+ }
120
+ if (to.transform) {
121
+ item.style.transform = to.transform
122
+ }
123
+ })
124
+
125
+ const totalDuration = (items.length - 1) * delay + duration
126
+
127
+ await new Promise<void>(resolve => {
128
+ if (animationTimeout) {
129
+ clearTimeout(animationTimeout)
130
+ }
131
+ animationTimeout = setTimeout(() => {
132
+ animationTimeout = null
133
+ isAnimating.value = false
134
+ items.forEach((item) => {
135
+ item.style.transition = ''
136
+ })
137
+ resolve()
138
+ }, totalDuration + 50)
139
+ })
140
+ }
141
+
142
+ function reset(): void {
143
+ const items = getItems()
144
+ items.forEach((item) => {
145
+ item.style.opacity = ''
146
+ item.style.transform = ''
147
+ item.style.transition = ''
148
+ })
149
+ isAnimating.value = false
150
+ hasAnimatedOnce = false
151
+ }
152
+
153
+ if (animateOnVisible) {
154
+ watch(
155
+ container,
156
+ async (newContainer) => {
157
+ if (newContainer && !hasAnimatedOnce) {
158
+ await nextTick()
159
+ const items = getItems()
160
+ if (items.length > 0) {
161
+ hasAnimatedOnce = true
162
+ animate()
163
+ }
164
+ }
165
+ },
166
+ { immediate: true }
167
+ )
168
+ }
169
+
170
+ onUnmounted(() => {
171
+ if (animationTimeout) {
172
+ clearTimeout(animationTimeout)
173
+ animationTimeout = null
174
+ }
175
+ })
176
+
177
+ return {
178
+ isAnimating,
179
+ animate,
180
+ reset
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Convenience function to apply stagger animation styles directly via CSS.
186
+ * Returns a style object that can be applied to list items.
187
+ *
188
+ * @param index - Item index in the list
189
+ * @param options - Animation configuration
190
+ * @returns Style object with animation delay
191
+ *
192
+ * @example
193
+ * ```vue
194
+ * <template>
195
+ * <ul>
196
+ * <li
197
+ * v-for="(item, index) in items"
198
+ * :key="item.id"
199
+ * :style="getStaggerStyle(index)"
200
+ * class="stagger-item"
201
+ * >
202
+ * {{ item.name }}
203
+ * </li>
204
+ * </ul>
205
+ * </template>
206
+ *
207
+ * <style>
208
+ * .stagger-item {
209
+ * animation: stagger-in var(--duration-normal) var(--ease-out) both;
210
+ * animation-delay: var(--stagger-delay);
211
+ * }
212
+ * </style>
213
+ * ```
214
+ */
215
+ export function getStaggerStyle(
216
+ index: number,
217
+ options: { delay?: number } = {}
218
+ ): Record<string, string> {
219
+ const { delay = 80 } = options
220
+ return {
221
+ '--stagger-delay': `${index * delay}ms`,
222
+ '--stagger-index': String(index)
223
+ }
224
+ }